| Home | Classes | Papers | Essays | Short Fiction | About |

CS211: Pointers

Table of Contents

Introduction

The finger pointing at the moon is not the moon — Buddhist saying1

The great benefit C provides over other programming languages is the fact that its runtime system is about as close to the hardware as any high-level programming language can conceivably get. To the novice programmer, this fact reveals itself to be a double-edged sword. C, unlike Java or other meticulously managed runtime languages, requires the programmer to be not just an expert in understanding programming abstractions, but to also be an expert in understanding how memory is managed during a program's execution. The average Python programmer likely has little to no idea about how the data they are manipulating is actually being handled by their machine. Conversely, the average C programmer must have some understanding as to what is happening under the hood during their computations. This reality flows forth from the most maligned and feared part of the language: pointers2.

This is often the most difficult topic of the semester. Do not be discouraged if it does not make sense right away!

Addresses

Recall that all variables in C represent locations in memory. Again, every variable that we declare in C code is nothing more than a named, symbolic stand-in for the physical location within the machine that holds data. When we declare a variable, say int x = 5, x is a useful symbol for the programmer because it is easier to reason about a symbol x than to reason about the relative address -4(%rbp).

All memory in C is "byte addressable". This means that the smallest memory unit that a C program can target is a byte3. Every single byte of memory then must have an address associated with it. The structure that maintains the addresses for a program's memory is called the address space. Below is a diagram which represents one in the abstract:

High Address (0xFFFFFFFFFFFFFFFF)
+------------------+
|      Stack       |  <- Local variables, function calls
|                  |     grows downward
|        |         |
|        v         |
|                  |
|                  |
|        ^         |
|        |         |
|                  |     grows upward
|       Heap       |  <- Dynamically allocated memory
+------------------+
|       BSS        |  <- Uninitialized global/static variables
+------------------+
|      Data        |  <- Initialized global/static variables
+------------------+
|      Text        |  <- Program code (read-only)
+------------------+
Low Address (0x0000000000000000)

There are a few things to note. One, the two dynamic memory regions, the stack and the heap, "grow" towards one another. This is what makes them dynamic—the size of each is not settled beforehand. Two, we can see that program code itself is contained within the address space in the text segment. Three, that the addresses are bounded between two hexadecimal numners, 0x0 and 0xFFFFFFFFFFFFFFFF, the latter of which is equivalent to 2^64-1. This is due to the fact that x86-64 machines will run 64-bit Operating Systems which provide processes with 64-bit addresses4.

What is a Pointer

A pointer is a variable type which stores a memory location. Despite all of the difficulties involved with dealing with pointers in code, the definition is rather simple. For example, if I declare a pointer int* p, then p can be assigned the memory location of another variable:

1: int main(){
2:   int x = 10;  // Declaring an integer x
3:                // with a value of 10
4: 
5:   int *p = &x; // Declaring an integer pointer p
6:                // with a value of the memory location of x
7: }

(All of the operators will be dealt with in more detail below.) Here, p does not store 10, but rather the memory location that the variable x represents. If x is at the relative address -4(%rbp) then p stores -4(%rbp), not the value contained at that address. Again, p is storing a reference to x. Before we get into more examples, I think it's best to describe all of the operators associated with pointers.

Defining a Pointer Type

Listing 2 defines an integer pointer p on Line 5. It does so using the type declarator *. Adding a * prior to the variable name indicates that it is a pointer type variable. For example, char *cp is a char pointer, double *dp is a double pointer, int *ip is an int pointer, and float *fp is a float pointer.

Pointer types can have multiple levels of indirection. This means that I can further alter some of the above definitions to include something like char **cpp which defines cpp as a pointer to a char pointer. This can be done arbitrarily5.

Pointer Operators

One of the holdovers of C development ocurring in the 1970s is that the symbol set to represent a multitude of operators was rather small. That paired with a desire to cut down on the potential visual noise of many bespoke operators led the language designers to reuse operators in different contexts. Many of these symbols we've seen in different contexts, but here they are used for pointer-specific purposes.

* operator

***​ is a unary operator which is used for dereferencing pointers.

Given an integer pointer p which holds a reference to a memory location storing the integer 10:

  • *p10

& operator

& is a unary operator which returns the memory location of the variable in its operand.

Given an int, x, which is stored at memory location 0x77ffff:

  • &x0x77ffff

+ operator

+ is the same binary addition operator we encountered before. The difference here is that only integers can be used when a pointer is involved. The expression resolves the sum of the pointer in the left operand with the integer offset in the right operand. It will evaluate (left operand location) + (sizeof(type) * offset):

Given an integer pointer, ip, which is pointing to memory location 0x0:

  • ip + 40x10 (on a standard x86-64 machine with a 4 byte int)

Given a character pointer, cp, which is pointing to memory location 0x0:

  • ip + 40x4 (on a standard x86-64 machine with a 1 byte char)

Dereferencing a Pointer

One of the trickiest parts when working with pointers is understanding what it means to dereference them. Dereferencing just means that you are telling the C runtime to "look into" the reference that is stored inside of the pointer. Again, the pointer points to something. Dereferencing has the program trying to get the thing that is being pointed to. Here's a simple example which takes Listing 2 and modifies it slightly:

1: #include <stdio.h>
2: int main(){
3:     int x = 10;
4:     int *p = &x;        // Here, * is used as a declarator
5:     printf("%d\n", *p); // Here, * is used as a dereference operator
6:   }

Line 3 declares an int, x, and sets its value equal to 10. Line 4 declares a pointer to an int, p, and sets its value equal to the memory location of x. Line 5 dereferences the p by jumping to the memory location stored in the variable p and returning the value contained therein. We can compile and run the program like so:

josephraskind@stargazer:/tmp/pointers$ gcc deref.c -o deref; ./deref
10

Why is 10 returned and not the memory location of x? Again, its because the C runtime has looked inside of the memory location stored in p and retrieved the value that it was storing.

Let's expand Listing 3 somewhat:

 1: #include <stdio.h>
 2: int main(){
 3:     int x  = 10;
 4:     int *p = &x;
 5:     printf("Value of &x:  0x%p\n", &x);  // Memory location of x
 6:     printf("Value of p:   0x%p\n", p);   // Value stored in p
 7:     printf("Value of &p:  0x%p\n", &p);  // Memory location of p
 8:     printf("Value of *p:  %d\n", *p);    // Dereferencing p
 9:     printf("Value of x:   %d\n", x);     // Value stored in x
10:   }

Let's compile and run the above:

1: josephraskind@stargazer:/tmp/pointers$ gcc deref_exp.c -o deref_exp; ./deref_exp
2: Value of &x:  0x0x7ffe140349ac
3: Value of p:   0x0x7ffe140349ac
4: Value of &p:  0x0x7ffe140349b0
5: Value of *p:  10
6: Value of x:   10

Let's take the output of Listing 6 one line at a time. Line 2 shows the output of &x which is the memory address that x was assigned. Line 3 shows that the p variable was storing exactly that memory address—this should make sense as p is a pointer to x. Line 4 shows the memory address of p; p's memory address is distinct from x's because they are two different variables. Line 5 shows the result from dereferencing p; the C runtime looks inside of 0x0x7ffe140349ac and finds the value 10. Line 6 shows the value of x, which we would expect to be 10.

Let's expand this one level further by introducing a double pointer pp:

 1: #include <stdio.h>
 2: int main(){
 3:     int x   = 10;
 4:     int *p  = &x;
 5:     int **p = &p;
 6:     printf("Value of &x:   0x%p\n", &x);   // Memory location of x
 7:     printf("Value of p:    0x%p\n", p);    // Value stored in p
 8:     printf("Value of &p:   0x%p\n", &p);   // Memory location of p
 9:     printf("Value of *p:   %d\n", *p);     // Dereferencing p
10:     printf("Value of x:    %d\n", x);      // Value stored in x
11:     printf("\n");
12:     printf("Value of pp:   0x%p\n", pp);   // Value stored in pp
13:     printf("Value of &pp:  0x%p\n", &pp);  // Memory location of pp
14:     printf("Value of *pp:  0x%p\n", *pp);  // Dereferencing pp
15:     printf("Value of **pp: 0x%p\n", **pp); // Dereferencing pp, twice
16:   }

And then we compile and run it:

 1: josephraskind@stargazer:/tmp/pointers$ gcc deref_exp_pp.c -o deref_exp_pp; ./deref_exp_pp
 2: Value of &x:   0x0x7fffb54bd364
 3: Value of p:    0x0x7fffb54bd364
 4: Value of &p:   0x0x7fffb54bd368
 5: Value of *p:   10
 6: Value of x:    10
 7: 
 8: Value of pp:   0x0x7fffb54bd368
 9: Value of &pp:  0x0x7fffb54bd370
10: Value of *pp:  0x0x7fffb54bd364
11: Value of **pp: 10

The same line by line analysis should be helpful. Line 9 outputs the address stored by pp which is equivalent to the address of p on Line 4. Line 10 outputs the address of pp. Line 11 outputs the value stored at the location pointed to by 0x0x7fffb54bd368 which happens to be the memory address of x, or what is stored in p. Line 12 shows that we can stack dereferences: (1) We dereference to look inside of the memory location of p to grab the memory location of x; (2) We dereference the memory location of x to get the value of x which is 10.

This works because a pointer is nothing more than a variable that stores a memory location:

     pp                p                 x
+----------+      +----------+      +----------+
|  0x7ffd  |----->|  0x7fff  |----->|    10    |
+----------+      +----------+      +----------+
   0x7ffc            0x7ffe            0x7fff

Dereferencing for Assignment

We can also use pointers to store values in memory locations. If we take our simple p example from Listing 2 and alter it slightly:

1: #include <stdio.h>
2: int main(){
3:     int x = 10;
4:     int *p = &x;
5:     printf("(Before) Value of x: %d\n", x);
6:     *p = 5; // Dereference with assignment
7:     printf("(After)  Value of x: %d\n", x);
8:   }

If we compile and run the above:

1: josephraskind@stargazer:/tmp/pointers$ gcc assignment.c -o assignment; ./assignment
2: (Before) Value of x: 10
3: (After)  Value of x: 5

What happened?! It turns out that the dereference operator returns both an lvalue and an rvalue, much the same as a simple named variable expression might. Recall that x is nothing more than a memory location playing dressup. That means when you have a memory location in the form of a pointer, p, then it can act in the same way that x does. The * on the left side of the assignment statement tells the compiler to treat whatever is stored in the pointer as a real, valid memory address just like x.

Dereferences That Go Wrong

Dereferences are not always guaranteed to work out. As per the C Standard spec, "If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined." In most situations, this will likely lead to a segmentation fault. Here's some code to set this up:

1: #include <stdio.h>
2: int main(){
3:     int *p = 0;
4:     printf("Value of *p: %d\n", *p);
5:   }

If we compile and run it we'll see:

josephraskind@stargazer:/tmp/pointers$ gcc bad_deref.c -o bad; ./bad
Segmentation fault (core dumped)

The code compiles just fine, but fails during the runtime execution of the program. This is because gcc has to respect the fact that p could point to somewhere meaningful and, as such, will generate code that tries to chase the reference. Of course, since it points to 0, or the null pointer, the address is invalid and does not store any date that can be interpreted as an int.

Dereferencing with Mixed Types

During our discussion on types I made mention of the fact that C is a weakly typed language. The biggest case for that comes in the ability to explicitly cast pointer types freely. For example, look at the code below:

 1: #include <stdio.h>
 2: int main(){
 3:   unsigned int x = 0xFFFFFF41;
 4:   int        *ip = &x;             // no casting, x is an int 
 5:   char       *cp = (char *) &x;    // casting to a char*
 6:   float      *fp = (float *) &x;   // casting to a float*
 7:   double     *dp = (double *) &x;  // casting to a double*
 8: 
 9:   printf("Value of *ip: 0x%x\n", *ip); // Printing hex of x
10:   printf("Value of *cp: %c\n", *cp);   // Printing x as if it was a char
11:   printf("Value of *cp: %s\n", cp);    // Printing x as if it was a string
12:   printf("Value of *fp: %f\n", *fp);   // Printing x as if it was a float
13:   printf("Value of *dp: %f\n", *dp);   // Printing x as if it was a double
14: }

After compiling and running it we get:

1: josephraskind@stargazer:/tmp/pointers$ gcc mixed.c -o mixed; ./mixed 
2: Value of *ip: 0xffffff41
3: Value of *cp: A
4: Value of *cp: A\377\377\377"$\377
5: Value of *fp: -nan
6: Value of *dp: 0.000000

C lets us freely cast the int * returned by &x and, when we do so, we lose the original, and valuable, type information we started with! Other, more strongly typed languages like Java would have stopped the sort of manipulation shown on Line 4—it makes no semantic sense to treat an int like a string, and yet C allows that to happen as long as we pass through pointers first. Again, this comes from the language's implicit assumption that the programmer knows what she is doing and that she wants a language which is willing to forgo a strong type system for something that allows this sort of low-level manipulation.

void Pointers

As per the C language spec, "The void type comprises an empty set of values; it is an incomplete object type that cannot be completed." This does not preclude the creation and use of a void pointer type. In fact, C programmers often use void pointers in their day to day tasks. Many standard library functions, like malloc, return void pointers as the absence of a type is necessary to preserving the abstraction associated with the function call.

Dereferencing void Pointers

It is impossible to capture the value of a void pointer using the dereference operator. This is because void does not map to a data type, but rather represents the absence of a type. void pointers can be cast to any other pointers and dereferenced afterwards (see the above section).

Arrays

Arrays refer to a special sort of type which represent contiguosly allocated nonempty values. Arrays have a base, or element, type associated with them. For example, we can define an integer array, arr, with ten elements using the following syntax: int arr[10]. Arrays are not guaranteed to have set default values. We can use an initializer to guarantee zeros with int arr[10] = {0}. Let's have a look:

1: #include <stdio.h>
2: int main(){
3:   int arr[5];                 // Declaring an int array of 5 elements
4:   for(int i = 0; i < 5; i++)
5:     printf("%d\n", arr[i]);   // The [] are used for selecting an element
6:   int arr_z[5] = {0};         // Declaring a new array initialized to 0
7:   for(int i = 0; i < 5; i++)
8:     printf("%d\n", arr_z[i]);
9: }

And after we compile and run the above:

 1: josephraskind@stargazer:/tmp/pointers$ gcc arr_init.c -o arr; ./arr
 2: 1894002662
 3: 32766
 4: 1254343277
 5: 22066
 6: -525053208
 7: 0
 8: 0
 9: 0
10: 0
11: 0

Lines 2-6 print out the elements of the unitialized array. Lines 7-11 print out the elements of the initialized array.

Arrays are contiguous in memory. This means that there is an entire block of memory reserved for the elements of that array side-by-side. When I write int x[5], that means that on my machine 20 bytes are reserved for the array x (5 4 byte ints). We can see this in code:

1: #include <stdio.h>
2: int main(){
3:   int arr[5] = {1,2,3,4,5};
4:   for(int i = 0; i < 5; i++)
5:     printf("0x%p: %d\n", &arr[i], arr[i]); // Print the memory location of each element and its value
6:   printf("%ld\n", sizeof(arr));            // Print the size of the array
7: }

After compiling and running the above:

1: josephraskind@stargazer:/tmp/pointers$ gcc contiguous.c -o arr; ./arr
2: 0x0x7ffd1aa80530: 1
3: 0x0x7ffd1aa80534: 2
4: 0x0x7ffd1aa80538: 3
5: 0x0x7ffd1aa8053c: 4
6: 0x0x7ffd1aa80540: 5
7: 20

You'll notice that every address is 4 spots higher than the last. Again, this is because each element is an int, and in x86-64 machines an int is usually 4 bytes.

[] operator

[] is an operator used for extracting values in an array. Given an integer array, n, of the first 10 natural numbers:

  • n[0]1
  • n[1]2
  • etc.

Arrays are indexed by 0. The first element matches to the index 0, the second element matches to the index 1, and so on.

[] also returns lvalues, so they can be used for assignment.

  • if n[0] = 10 then n[0]1

Multidimensional Arrays

Arrays can take on more than 1 dimmension. For example, I could construct a 3x4 matrix using the following syntax: int matrix[3][4]. This creates an array of 12 integer elements which can be indexed using two sets of brackets. Conceptually we can think of the matrix looking like:

         [0]        [1]        [2]        [3]
     +----------+----------+----------+----------+
[0]  |    1     |    2     |    3     |    4     |
     +----------+----------+----------+----------+
[1]  |    5     |    6     |    7     |    8     |
     +----------+----------+----------+----------+
[2]  |    9     |    10    |    11    |    12    |
     +----------+----------+----------+----------+

But in memory it will look like:

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
|   1    |   2    |   3    |   4    |   5    |   6    |   7    |   8    |   9    |   10   |   11   |   12   |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
  [0][0]   [0][1]   [0][2]   [0][3]   [1][0]   [1][1]   [1][2]   [1][3]   [2][0]   [2][1]   [2][2]   [2][3]

Arrays vs Pointers

Functionally, there's not an awful lot separating arrays and pointers. The main difference is that arrays define a contiguous block of memory. Pointers do not. Below we can see similarities in their syntax/behavior:

 1: #include <stdio.h>
 2: int main(){
 3:   int arr[5] = {1,2,3,4,5};  // Initializing int array
 4:   int *p = &(arr[0]);        // Getting the pointer to the first elem
 5: 
 6:   printf("Value of arr[1]:   %d\n", arr[1]);
 7:   printf("Value of p[1]:     %d\n", p[1]);
 8: 
 9:   printf("Value of *(arr+3): %d\n", *(arr+3));
10:   printf("Value of *(p+3):   %d\n", *(p+3));  
11: }

Compiling and running the above:

1: josephraskind@stargazer:/tmp/pointers$ gcc arr_and_p.c -o arr; ./arr
2: Value of arr[1]:   2
3: Value of p[1]:     2
4: Value of *(arr+3): 4
5: Value of *(p+3):   4

We can see that pointers and arrays are treated almost identically from the perspective of the [], *, and + operators.

Dynamic Allocation

Until now, memory allocation has been the compiler's job. We've never had to worry much about reserving space on the stack to make sure there was a place to store our data because the compiler would guarantee there was always enough room. Sometimes, however, it's advantageous to be more particular about memory management. The C standard library provides functions like malloc and calloc to do exactly that.

Why Dynamic Allocation?

The best way to explain the advantages of dynamic allocation is to illustrate what happens when we don't bother with it:

 1: #include <stdio.h>
 2: #include <stdlib.h>
 3: #include <string.h>
 4: 
 5: char *make_greeting(char *name){
 6:   char greeting[50];             // Reserving 50 bytes statically
 7:   strcpy(greeting, "Hello, ");   // Copying string constant "Hello, " into greeting
 8:   strcat(greeting, name);        // Concatenating name with greeting.
 9:   return greeting;
10: }
11: 
12: int main(){
13:   char *g = make_greeting("Joe");
14:   printf("%s\n", g);
15:   free(g);
16: }

In the above code I'm sending a string constant to the function make_greeting which is meant to prepend "Hello, " to it and return the resulting string. Let's see what happens when I try to compile and run it:

1: josephraskind@stargazer:/tmp/pointers$ gcc greeting_static.c -o greeting_static; ./greeting_static
2: greeting_static.c: In function 'make_greeting':
3: greeting_static.c:9:10: warning: function returns address of local variable [-Wreturn-local-addr]
4:     9 |   return greeting;
5:       |          ^~~~~~~~
6: Segmentation fault (core dumped)

We get a segmentation fault! We can already see that the compiler is giving us a hint about what will, and did, happen. The char array greeting is a local variable whose existence is tied only to the function call. The moment the make_greeting function is exited the compiler is free to forget about any local variables and their corresponding memory locations. What's returned by make_greeting is a pointer to a variable that no longer exists. As such, when printf tries to interpret the bits that g points to there's an error whic causes a halt to the program execution.

Now let's try it with dynamic allocation using malloc:

 1: #include <stdio.h>
 2: #include <stdlib.h>
 3: #include <string.h>
 4: 
 5: char *make_greeting(char *name){
 6:   char *greeting = malloc(50);   // Reserving 50 bytes dynamically
 7:   strcpy(greeting, "Hello, ");   // Copying string constant "Hello, " into greeting
 8:   strcat(greeting, name);        // Concatenating name with greeting.
 9:   return greeting;
10: }
11: 
12: int main(){
13:   char *g = make_greeting("CS211");
14:   printf("%s\n", g);
15:   free(g);
16: }

After we compile and run it:

1: josephraskind@stargazer:/tmp/pointers$ gcc greeting_dynamic.c -o greeting_dynamic; ./greeting_dynamic
2: Hello, CS211

And with that little change we no longer experience a segmentation fault! The greeting variable is still local, and the variable memory itself is forgotton, yet the program runs without any problems. This happened because the standard library function malloc reserved memory on the heap, outside of the local stack. The memory address where those contiguous bytes were reserved was returned by malloc and placed within the greeting variable. This is also why g is able to be referenced without error—it was passed back the memory location returned by malloc.

Because the heap is distinct from the stack there is no danger of the reserved memory on the heap being overwritten from other function calls or other local variable declarations.

How to Explicitly Allocate Memory?

Although it may be difficult to grasp conceptually, it is actually very easy to use the C standard library functions dedicated to dynamic allocation. For this class, we'll only focus on malloc and calloc. They are both in the stdlib.h header file.

malloc

malloc stands for "memory allocation". It is a function that takes an integer and returns a void pointer to a memory location corresponding to the beginning of the reserved space. Below shows an example of allocating a single int as well as an int array:

1: #include <stdio.h>
2: #include <stdlib.h>
3: 
4: int main(){
5:   int *i     =  (int *) malloc(sizeof(int));      // Allocating a single int
6:   int *i_arr =  (int *) malloc(sizeof(int) * 5);  // Allocating int[5]
7: }

Just like when defining local variables, there is no guarantee that the integers contained therein are zeros. We can guarantee that with calloc:

1: #include <stdio.h>
2: #include <stdlib.h>
3: 
4: int main(){
5:   int *i     =  (int *) calloc(1, sizeof(int));      // Allocating and zeroing a single int
6:   int *i_arr =  (int *) calloc(5, sizeof(int));  // Allocating and zeroing int[5]
7: }

calloc takes the number of elements desired and the size of each one and sets all bits in the contiguous space to 0.

Since malloc returns a void * it's a best practice to explicitly cast it to the desired pointer type.

What is a Memory Leak?

You may have notice on Line 15 in Listing 26 there is a reference to a function called free. free de-allocates memory that has been allocated with members of the malloc family. Without the use of free, there is nothing to "clean up" unused memory. This is what makes dynamic memory allocation explicit on the part of the programmer---you must always clean up your mess. Memory leaks are not easy to spot during program execution, so tools like valgrind can be useful for checking for leaks. Here is an example of me using valgrind on the code from Listing 29:

 1: josephraskind@stargazer:/tmp/pointers$ gcc m.c -o m; valgrind ./m
 2: ==7175== Memcheck, a memory error detector
 3: ==7175== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
 4: ==7175== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
 5: ==7175== Command: ./m
 6: ==7175== 
 7: ==7175== 
 8: ==7175== HEAP SUMMARY:
 9: ==7175==     in use at exit: 24 bytes in 2 blocks
10: ==7175==   total heap usage: 2 allocs, 0 frees, 24 bytes allocated
11: ==7175== 
12: ==7175== LEAK SUMMARY:
13: ==7175==    definitely lost: 24 bytes in 2 blocks
14: ==7175==    indirectly lost: 0 bytes in 0 blocks
15: ==7175==      possibly lost: 0 bytes in 0 blocks
16: ==7175==    still reachable: 0 bytes in 0 blocks
17: ==7175==         suppressed: 0 bytes in 0 blocks
18: ==7175== Rerun with --leak-check=full to see details of leaked memory
19: ==7175== 
20: ==7175== For lists of detected and suppressed errors, rerun with: -s
21: ==7175== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Valgrind runs the code in a virtual environment which uses reference counting to ensure an accurate sweep for memory leaks. The two calls to malloc return addresses which are never subsequently cleaned up and, as a result, lead to 24 bytes unaccounted for. If we add two free calls to the end of the code we'll find valgrind will not detect any leaks:

1: #include <stdio.h>
2: #include <stdlib.h>
3: 
4: int main(){
5:   int *i     =  (int *) malloc(sizeof(int));      // Allocating a single int
6:   int *i_arr =  (int *) malloc(sizeof(int) * 5);  // Allocating int[5]
7:   free(i);
8:   free(i_arr);
9: }

After compiling and running:

 1: josephraskind@stargazer:/tmp/pointers$ gcc m.c -o m; valgrind ./m
 2: ==7221== Memcheck, a memory error detector
 3: ==7221== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
 4: ==7221== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
 5: ==7221== Command: ./m
 6: ==7221== 
 7: ==7221== 
 8: ==7221== HEAP SUMMARY:
 9: ==7221==     in use at exit: 0 bytes in 0 blocks
10: ==7221==   total heap usage: 2 allocs, 2 frees, 24 bytes allocated
11: ==7221== 
12: ==7221== All heap blocks were freed -- no leaks are possible
13: ==7221== 
14: ==7221== For lists of detected and suppressed errors, rerun with: -s
15: ==7221== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Memory leaks are less of an observable problem for our class projects and labs, but it is important you are aware of them including how they can happen and how they can be fixed.

Function Pointers

Our last for foray into pointers leads us into some really unintuitive territory with function pointers. It turns out that not only can we have pointers that point to data and other pointers, but we can also have pointers that point to functions. How is that possible? Recall that pointers store locations in memory. If we combine that with the knowledge that functions are also stored in memory then it's only logical that pointers should also be able to point to functions. Here's an example:

 1: #include <stdio.h>
 2: 
 3: int cube(int c){
 4:   return c * c * c;
 5: }
 6: 
 7: int square(int s){
 8:   return s * s;
 9: }
10: 
11: int add_one(int a){
12:   return a + 1;
13: }
14: 
15: int apply(int x, int (*fn)(int)){ // The second argument is a function pointer which takes an int and returns an int
16:   return fn(x);                   // Calling a function using the pointer
17: }
18: 
19: int main(){
20:   int (*funcs[3])(int) = {add_one, square, cube}; // An array of function pointers
21:   int v = 10;
22:   for(int i = 0; i < 3; i++){
23:     int res = apply(v, funcs[i]);                 // Providing function pointers as arguments to the apply function
24:     printf("%d\n", res);
25:   }
26: }

Wow…what a nightmare! If we restrict our view to the main method we can see a declaration of a variable funcs which is an array of function pointers. The pointers are to functions which take an int (shown to the right of the parentheses) and return an int (shown to the left of the parentheses). I then send each function pointer into a function called apply which also takes a regular int. Line 16 shows that apply calls the function pointer's function reference with the argument provided by the call to apply. We can see the the output below:

1: josephraskind@stargazer:/tmp/pointers$ gcc func_p.c -o func_p; ./func_p
2: 11
3: 100
4: 1000

First add_one is called, then square, then cube.

Function pointers are a very powerful abstraction that allows C to operate similarly to higher-level functional programming languages like Lisp and Haskell.

Exercises

  1. Compile and run Listing 5 on your own machine. Do the memory addresses match those shown in the lecture? Why or why not? What stays the same between runs on the same machine?
  2. Draw a pointer diagram in the style of Listing 9 for the following code before running it, then verify by compiling and printing the addresses:

    int x = 42;
    int *p = &x;
    int **pp = &p;
    
  3. Write a C program that declares an int x = 5 and a pointer int *p = &x. Using only *p (no direct reference to x), change the value of x to 100 and print it. Verify that printing x directly also shows 100.
  4. The lecture shows that dereferencing a null pointer causes a segmentation fault as in Listing 12. Compile and run it yourself. Then run it under valgrind and record the output. What additional information does valgrind provide compared to just running the program?
  5. Write a C program that declares int x = 0xFFFFFF41 and prints it as an int, a char, and a float by casting the pointer, as in Listing 14. Before running, predict what each output will be based on what you know about how each type interprets bits.
  6. The lecture states that arrays and pointers behave similarly. Write a program based on Listing 22 that declares an int array of 5 elements and a pointer to its first element. Access the third element using four different methods: arr[2], *(arr+2), p[2], and *(p+2). Verify all four produce the same result.
  7. Write a C program that declares an uninitialized int array of 5 elements as in Listing 16 and prints each element alongside its memory address. What is the distance in bytes between each address? Does this match what you expect given the size of an int?
  8. The lecture states that the [] operator returns an lvalue. Write a program that uses [] on the left side of an assignment to modify elements of an array, then prints the modified array to verify.
  9. Rewrite the static allocation version of make_greeting from Listing 24 to use malloc instead as shown in Listing 26. Compile both versions and compare the compiler warnings. Explain in your own words why the malloc version works and the static version does not.
  10. Write a program that uses malloc to allocate an int array of 10 elements, sets each element to its index multiplied by 2, prints all elements, and then frees the memory. Run it under valgrind to confirm there are no leaks.
  11. Write the same program as above using calloc instead of malloc. Print the array before setting any values. What do you observe and why does it differ from malloc?
  12. The lecture shows that valgrind reports 24 bytes leaked for two malloc calls in Listing 30. Explain why the total is 24 bytes given that only 4 bytes were requested for the single int and 20 bytes for the int array. Does this match what you'd expect?
  13. Write a program that intentionally creates a memory leak by calling malloc without a corresponding free. Run it under valgrind and record the output. Then fix the leak and run valgrind again to confirm it reports no leaks.
  14. Declare a 3x4 int matrix as in Listing 20 and initialize it with values 1 through 12. Print each element alongside its memory address using nested for loops. Verify that the addresses are contiguous and separated by 4 bytes.
  15. The lecture shows that the + operator on pointers accounts for the size of the type. Write a program that declares both an int array and a char array and prints the addresses of each element. What is the difference in byte offsets between elements of each array? Does it match what the lecture predicts?
  16. Write a program using function pointers based on Listing 33 that defines three functions: one that doubles its input, one that negates it, and one that returns its absolute value. Store them in a function pointer array and apply each to the value -5 using an apply function.
  17. The lecture states that void pointers cannot be dereferenced directly. Write a program that allocates memory with malloc, stores the result in a void *, then casts it to an int * and stores a value through it. Print the value by dereferencing the cast pointer.
  18. Write a program that declares int x = 10, int *p = &x, and int **pp = &p. Using only **pp, change the value of x to 99. Print x, *p, and **pp to verify all three reflect the change.
  19. The lecture mentions that the text segment stores program code in read-only memory. Based on what you know from the memory lecture about the .rodata section, explain why attempting to modify a string constant causes a segmentation fault but modifying a char array initialized from a string constant does not. Write a program demonstrating the difference between the two.
  20. Write a program that uses a function pointer to implement a simple calculator. Define four functions for addition, subtraction, multiplication, and integer division, each taking two int arguments and returning an int. Store them in a function pointer array and loop through all four, applying each to the values 10 and 3 and printing the result.

Footnotes:

1

As recorded in Types and Programming Languages by Benjamin Pierce.

2

Dun DUn DUN!!!

3

Recall that a "byte" in C just means "addressable unit of data storage large enough to hold any member of the basic character set of the execution environment". In our x86-64 machines, that maps to a real byte in the binary sense (8-bits), but it doesn't necessarily have to be the case.

4

This is not the course to delve too deep on what's happening behind the scenes. Any operating systems course worth its salt will do so.

5

Oftentimes, the level of indirection does not go beyond five pointers deep. Theoretically, it can and God help you if it does.

Contact: [email protected] | rss feed | Compiled with org-mode | Licensed under CC BY-NC-SA 4.0