Skip to content
ajd98 edited this page Apr 12, 2018 · 9 revisions

Exercise 2: Memory and Arrays

In contrast to some languages (e.g., Python) that allow the programmer to code with little concern for how memory is used, C programming affords relatively direct control over how memory is used and accessed. Therefore, if you are coming from a language such as Python, understanding memory usage in C may take some effort.

An introduction to memory

Memory--often random access memory (RAM) at the hardware level--enables you to store variables and functions. Whenever you declare a variable, the program requests memory for that variable. For example, on my machine the int is 32 bits, so writing int my_variable; will generate a request for 4 bytes (1 byte = 8 bits) of memory at run time.

We can think of memory being laid out as a very large array of ones and zeros, each of which is called a bit. Typically this array is divided into blocks of 8, each of which is called a byte. Associated with each byte is a numerical address. Here is a visualization of memory organization:

 address    |-byte-|
       0    00000000
       1    00000000
       2    00000000
       3    00000000
       4    00000000   
     ...
 8729315    00000000
 8729316    00000000
     ...    00000000

You won't usually see memory addresses written as decimals, like they are in the above example. Instead, you will see them written in hexadecimal. The prefix "0x" emphasizes that the values are base-16.

       address    |-byte-|
0x000000000000    00000000
0x000000000001    00000000
           ...    00000000
0x000000000009    00000000
0x00000000000a    00000000
0x00000000000b    00000000
0x00000000000c    00000000
0x00000000000d    00000000
0x00000000000e    00000000
0x00000000000f    00000000
0x000000000010    00000000
0x000000000011    00000000
           ...
0x7ffce8f78a2c    00000000
0x7ffce8f78a2d    00000000
0x7ffce8f78a2e    00000000
0x7ffce8f78a2f    00000000
0x7ffce8f78a30    00000000
           ...

Let's say I declare a char, which is typically 1 byte, and assign a value of 'a', which corresponds to a numerical value of 61: char my_character = 'a';. I can find the memory address of my_character using the & operator:

printf("%p\n", &my_character);

(Note that the %p format specifier is used for printing memory addresses.) When executed, the above code gave me the following output:

0x7ffce8f78a2c

If I were to look at the specified address, I would see something like the following:

       address    |-byte-|
           ...
0x7ffce8f78a2b    00110001
0x7ffce8f78a2c    00111101  <- the char I declared; note that 00111101 is 61 in binary.
0x7ffce8f78a2d    00001011  <- "junk" values; this memory is just some unknown part of my program.
0x7ffce8f78a2e    00000000
0x7ffce8f78a2f    00000000
           ...

The stack and the heap

Memory in C programs can be divided into two categories: the stack and the heap. Stack memory is allocated when you declare a variable, and it is automatically freed for reuse when the function in which the variable was declared returns. Heap memory is allocated when the programmer specifically asks for a block of raw memory, which can be done using the malloc (memory allocate) function, included in stdlib.h. Whereas stack memory is automatically freed, heap memory must be manually freed using the free function. The malloc function returns the address of the block of raw memory; this address should be stored in a pointer.

Pointers

Pointers are types of variables that hold a memory address. When we write C code, we also include extra information that tells the computer how to interpret the memory address--for example, whether the memory holds int, float, or other values. The syntax to declare a pointer is to give the type of whatever is stored at the memory address followed by a * symbol. For example, the following code declares a pointer called my_pointer, with which we intend to store the memory address of an int:

int* my_pointer;

Similarly, we could declare a pointer to other types:

float* my_float_pointer;
double* my_double_pointer;
char* my_character_pointer;
void* my_void_pointer;
int** my_pointer_to_a_pointer_to_an_int;

Declaring a pointer as shown above does not allocate memory at the location to which the pointer points; the pointer current stores a "junk" value. We can use the "address of" operator to set the value of my_pointer to a valid memory address:

int* my_pointer;
int some_value = 4;
my_pointer = &some_value;

Now, my_pointer stores the memory address of the variable some_value. To be clear, my_pointer now has a value such as 0x7ffda5f660dc. To obtain the value stored at that memory address, we need to dereference the pointer, which can be done using either of the following syntaxes:

*my_pointer;
my_pointer[0];

Thus, writing printf("%d\n", my_pointer[0]); would result in 4 being printed to the terminal.

Pointers as arrays

Rather than just pointing to a single value, a pointer can point to an entire block of memory. Suppose we do the following:

n_elements = 10;
int* my_array = (int*)malloc(n_elements*sizeof(int));

The above code creates my_array, which is a pointer to an int. The value of my_array is set to the expression on the right hand size of the = sign, which we can break down piece-by-piece. First, the sizeof function calculates the size (in bytes) of the specified type. On my machine, the int is 32-bits, so sizeof(int) returns 4 (since there are 8 bits per byte). Multiplying sizeof(int) by n_elements gives the size of the block of memory needed to store n_elements values of type int: in my case, 40 bytes. We pass this size to the malloc command, which allocates the desired amount of memory and returns the memory address of the start of the block of memory. We write (int*) to cast the memory address such that its type is "pointer to an int", and we assign that value to the pointer my_array.

Now that we have allocated a block of memory for 10 integers and stored the memory address of the 0th integer in my_array, we can assign values to the memory address:

my_array[0] = 1;
my_array[1] = 4;
my_array[2] = 9;

Now, the first 4 bytes of the block of memory hold the value 1, the second four bytes of the block of memory hold the value 4, and the third four bytes of the block of memory hold the value 9. A more elegant way to look at this, is that the block of memory is a array of integers, and elements 0, 1, and 2 have are 1, 4, and 9, respectively. The rest of the elements remain uninitialized and hold junk values.

Using pointers, we could (for example) do vector addition:

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

// Add vectors. c[i] = a[i] + b[i]
void
vector_add(int* a, int* b, int* c, int length)
{
  int i;
  for (i=0;i<length;i++) {
    c[i] = a[i] + b[i];
  }
}

int
main (void)
{
  // The number of elements in each vector
  int length = 10;

  // Allocate memory for vectors a, b, and c
  int* a = (int*)malloc(length*sizeof(int));
  // Check that memory was successfully allocated
  if (a == NULL) {
    fprintf(stderr, "Memory error. Unable to allocate memory for vector 'a'.\n");
    exit(EXIT_FAILURE);
  }
  int* b = (int*)malloc(length*sizeof(int));
  if (b == NULL) {
    fprintf(stderr, "Memory error. Unable to allocate memory for vector 'b'.\n");
    exit(EXIT_FAILURE);
  }
  int* c = (int*)malloc(length*sizeof(int));
  if (c == NULL) {
    fprintf(stderr, "Memory error. Unable to allocate memory for vector 'c'.\n");
    exit(EXIT_FAILURE);
  }

  // Initialize a, b, and c.  We set a[i] = b[i] = i, and we set c[i] = 0
  int i;
  for (i=0;i<length;i++) {
    a[i] = i;
    b[i] = i;
    c[i] = 0;
  }

  // Test vector_add
  vector_add(a, b, c, length);

  // Print out c to check the result
  for (i=0;i<length;i++) {
    printf("Element %d of c is: %d\n", i, c[i]);
  }

  // Finally, free the memory for a, b, and c.
  free(a);
  free(b);
  free(c);
}

Take note of the following:

  • The function vector_add returns type void. Since the function stores its output in one of its arguments (c), it does not need to return a value.
  • When we allocate memory, it is important to check that memory allocation was successful. If memory was not available, malloc will return a NULL pointer, for which we explicitly check.
  • If memory allocation fails, we can print to stderr rather than stdout by using the fprintf function together with stderr as an argument.
  • If memory allocation fails, we force the program to exit using exit(EXIT_FAILURE), which tells the system that the program did not complete correctly.
  • After we are done with a, b, and c, we free the associated memory. Failure to free memory may cause a memory leak, in which a program continually consumes more memory. This is a bad thing!

Clone this wiki locally