Return to lecture notes index

February 19, 2008 (Lecture 10)

Overview

Today we're going to talk about the computer's memory, how we locate objects stored within that memory, and C's special operators for dealing with memory. We'll also talk about 1D arrays in C: the mechanism by which they are implemented, how they are organized within memory, and how they are manipulated within code.

One Model of a Computer's Memory

People often say that "Computers work with numbers.", or "Inside of a computer everything is a number." And, this really is true. Within a computer's memory everything is represented in a way that can be read aas a number.

Computers store information using binary, a Base-2 encoding scheme. What does that mean? Well, by way of example, people use a Base-10 numbering system. We've got 10 digits: 0 - 9. Computers use only two digits: 0 and 1.

The reason for this is that it is much harder to construct a fast, reliable circuit that can distingush among 10 states than it is, for example, to build one that can distinguish between two states: ON and OFF. So, computers use two-state circuits, resulting in a Base-2 representation.

If it helps, consider a light on a switch and a light on a dimmer. If the light is switched ON or OFF, it is really easy to tell which state it is in. But, if we consider the dimmer, it much more difficult, for example, to distinguish 70% from 80%, especially if the lights flicker a bit, vary in intensity with the quality of the bulbs or electricity, or if there's outside light leaking in.

Regardless, a computer's memory is basically a long stream of on-off switches. We represent each such switch as 0-ON or 1-OFF. But, it is very difficult to manage a long stream of such binary digits (bits). So, we break them into groups of eight, called octets or bytes. Since each byte contains eight bits, it can be represented as a binary number or, for a more compact notation, a decimal number between 0 and 255:

    27    26    25     24   23   22   21    20
  128    64    32    16    8    4    2    1

    1     1     1     1    1    1    1    1    = 256
    0     1     0     1    0    1    1    1    = 93
    0     0     0     0    0    0    0    0    = 0
  

Sometimes, when looking into memory for a value, we look only at one byte. Each individual letter is usually represented within a single byte. Type 'man ascii' to see the ASCII table, which provides one mapping from letters to byte-sized numbers.

Sometimes, however, we look at two or more adjacent bytes as if they, together, were one object. This is done, for example, to represent numbers larger than a byte, strings of characters, or complex records consisting of several pieces of information.

But, regardless of the size of the object in which we are interested, we identify it by two pieces of information: Its address and its size. If we view the computer's memory as an array of bytes, an object's address is nothing more than its index. In other words, an object's address tells us where it is stored within memory. If we combine this with its size, we can examine exactly the object within memory the size of the object in which we are interested, we identify it by two pieces of information: Its address and its size. If we view the computer's memory as an array of bytes, an object's address is nothing more than its index. In other words, an object's address tells us where it is stored within memory. If we combine this with its size, we can examine exactly the object within memory, e.g. "Give me 4 bytes beginning with the 2000th byte.

By convention, addresses are written out in hexidecimal, a Base-16 numbering system. It uses digits 0-9, just like base-10, as well as A, B, C, D, E, and F. A represents 10, B=11, C=12, D=13, E=14, and F=15. This is done because it is easy to convert between binary and hex, as hex is a power of two. To convert, one can just convert each digit individually:

Notice that the hexadecimal number is prefixed with "0x". This is a notation that reminds us that the number is Base-16, not for example, Base-10. This notation is not universal -- it is a "C Thing".

  0x10FA

  1      0      F      A
  0001   0000   1111   1010

  10FA = 0001000011111010
  

Data Types and Pointer Types

C includes many of the same data types as java: int, float, long, double, char. But, it does not have a boolean type. It also includes "unsigned" versions of these types, via the qualifier "unsigned", e.g., "unsigned int". There is even an "unsigned char". There is no boolean. For each type, there is a corresponding "pointer type". One declares a pointer by using the * as part of the variable declaration. For example, "int *ip;" declares a variable, "ip", that is a "pointer to an integer". It can hold the address of an integer. It is worth noting that, although the * appears to associate with the variable name -- it is actually part of the type name.

BTW, "int **ipp;" is legal two, it represents a "pointer to an integer pointer". There surely can be multiple stars.

C's Address-Related Operators

The *-asterisk has a few meanings in C. As you know, when used as a binary operator, it means "multiply". And, when used as part of a variable declaration, it means "pointer to".

When used as a unary operator, an operator with only one operand, it means "value of". This operator looks up the value stored at a particular address The &ersand operator is a related unary operator -- it gives the address of an object.

Take a quick look at the example below. Notice that we alias "number" using "numberp". In other words, we make numberp point to "number" so that "number" and "*numberp" refer to the same thing. Notice that changing one affects the other.

  #include <stdio.h>

  int main () {
    int number;
    int *numberp;

    numberp = &number;
    number = 5;
    printf ("%d %d\n", number, *numberp);
    *numberp = 6;
    printf ("%d %d\n", number, *numberp);
    number=8;
    printf ("%d %d\n", number, *numberp);

    return 0;
  }
  

As a quick game, let's find out, how wide is an int? In other words, how many bytes des an int occupy in memory. We could do this with sizeof() by giving it either a) the type or b) a variable name. Or, we can do it by taking the address of to adjacent ints and subtracting:

By the way, technically speaking sizeof() doesn't report the number of bytes in a type -- it reports the number of char's in the type. And, as it turns out, in C89, a char is one byte. But, in newer versions of C, chars are allowed to be larger. In these environments, if "char" is two bytes, sizeof() reports in two byte units.

  #include <stdio.h>

  int main () {
    int number1;
    int number2;
    int size;

    size = (unsigned int)&number1 - (unsigned int)&number2;

    /* Notice tat, no mater how we count it, ints are 4 bytes wide */
    printf ("%u\n", size);
    printf ("%u\n", sizeof(int));
    printf ("%u\n", sizeof(number1));

    return 0;
  }

  

The use of the *operator is often known as dereferencing a variable. The &operator is often read as "the address of". Using pointers to get to values is sometimes called indirection.

"Passing By Reference"

As we've already discussed, much like Java, C passes arguments by reference. In other words, the arguments on the caller's side and the parameters within the function are different variables. The arguments get copied exactly once into the parameters to initialize them. The parameters are never copied back.

We rememeber our prior example of this, swap(). swap() was broken because it switched around the parameters within the function -- but in no way affected the original arguments outside of the function.

Check out the version of swap() below. It uses what C programmers like to call "Pass by reference". By passing the addresses of two variables as arguments, the addresses get copied by value into the function. Then, within the function, indirection can be used. The pointers can be dereferenced and the original variables can actually be changed.

  void swap (int *x, int *y) {
    int temp;

    temp = *x;
    *x = *y;
    *y = temp;
  }
  

Isn't that pretty? the function's pointer variables "number1" and "number2" never change. And that's a good thing -- changing them won't outlive the function. But, we use them to get to main()'s "number1" and "number2" via indirection. And, in this way, we change main()'s variables in an enduring way.

It is important to note that, although C programmer's like to call this idiom "pass by reference", it is the programmer, not the language that is doing the pass by reference. The arguments are still being passed into the parameters by value -- they are copied in. It is the programmer that is finding the address of the two variables -- and then allowing C to copy the address, rather than the value, into the function. And, it is also the programmer that is doing the dereferencing within the function. If C had a pass by reference mechanism, such as the one available in C++, the language would do this automatically and transparently.

1D Arrays

In Java, array's were "First-class objects". This is not the case in C, where arrays are actually a primitive data type. And, this isn't surprising -- C has no real objects.

The syntax to create an array in C is slightly different than Java. There is no call to "new" -- as a primitive, the array is created directly through the declaration. This is because the declaration is for the array -- not an implicit reference to it.

The syntax for a declaration is slightly different also -- the brackets are near the identifier name, not the type name. Check out the example below:

  int numbers[10]; /* declaration */

  numbers[5] = 5; /* assignment */
  printf ("%d\n", numbers[5]); /* looking at the value */
  

In C, an array is created by allocating space to pack multiple instances of the base type next to each other in memory. This means that if, for example, an int is 4 bytes wide, each element will begin in memory 4 bytes past the prior one.

Consider the code below. It will produce warnings, please don't worry about them -- for the purpose of this example only, I'd rather have the code short:

  #include <stdio.h>

  int main () {
    int numbers[10];
    int *numberp = numbers;

    printf ("%p %p\n", numbers, &numbers);
    printf ("%p %p %p\n", numbers, &numbers[1], &numbers[2]);
    printf ("%p %p %p\n", numberp[0], numberp[1], &numberp[2]);

    return 0;
  }
  

Notice that in the example above, the first printf() prints the same number twice. "%p" is the formatting string for a pointer. And, we get the same value for "numbers" as we do for "&numbers". This is because the &operator is defined to "do nothing" when applied to an array.

Also notice that the numbers printed by the second printf() are 4 bytes apart -- this is because an integer is 4 bytes. If you compile on a different system, the distance between the two should match sizeof(int).

The third printf shows that we can actually use array notation upon a pointer. The compiler, usually the preprocessor, actually converts it to pointer notation for us. This is done using "pointer arithmetic" as described within the next section.

Pointer Arithmetic

Well, if the elements of our array are 4 bytes wide, and consequently 4 bytes apart, we should be able to get there by starting out with the address of "numbers" and adding 4 bytes for each element of the array. In other words numbers[3] should begin at the address of numbers + 4*sizeof(int). One would think that the following would be equivalent:

  printf ("%d\n", numbers[5]);
  printf ("%d\n", *(numbers+5*sizeof(int)));
  

But, although the right idea, this doesn't turn out quite right. The reason is that C defines pointer arithmetic a bit differently than integer arithmetic. When adding or subtracting from a pointer, the size of the pointer's base type is automatically multiplied by the value that you provide. This is because 99.99% of the time a programmer is manipulating a pointer arithmetically, the programmer is manipulating an array. C, like any good programming language, is trying to make the common case convenient. Again, I know this compiles with warnings -- this is okay for example purposes only.

So, code correctly showing the equivalence is below:

  #include <stdio.h>

  int main () {
    int numbers[10];
    int *numberp;

    numbers[5] = 5;

    printf ("%d %d\n", *(numbers+5), numbers[5]);
    printf ("%p %p\n", &(numbers[5]), (numbers+5));
    printf ("%d %d\n", numberp[5], *(numberp+5));

    return 0;
  }
  

Take a close look at the third printf() above. See the rewriting of the array notation into pointer arithmetic? When a pointer is used to access the elements of an array, as we said before, the array notation is converted into pointer notation for you. Now you see the translation:

    array[index] ===> *(array + index)
  

So, what do you do if you really want to add, let's say 4 bytes, to a pointer? You cast it to an "unsigned long" and go from there. Some folks suggest casting it to a char -- but remember, a char isn't guaranteed to be 1 byte, never has been. If you want to do integer math, cast to an integer type:

  #include <stdio.h>

  int main () {
    int numbers[10];
    int *numberp;

    numbers[5] = 5;
    numbers[6] = 6;

    numberp = (int *)((unsigned long)(numbers+5) + 4);

    printf ("%d %d\n", numbers[6], *numberp);

    return 0;
  }
  

Pointer Types

So, from our earlier explanation of a computer's memory, it should make sense that there is no real difference between an "int *" and a "char *", or any other type of pointer. Any pointer is nothing more than an unsigned integer type used as an address into memory. It doesn't really matter what type of pointer it is.

So, why then does C have differnt types fo pointers rather than simple one "pointer" type? The examples above should give you a hint. It isn't that the pointers contain different stuff -- they all contain an address into the same memory. And, it isn't that they are used differently -- it is the same *, &, and []-notation.

There are three reasons. The first is that, without the type information, an explicit cast would always be required to dereference -- or the compiler wouldn't know how many bytes to use for the value.

The second reason is pointer arithmetic. Since, as above, C wouldn't know the width of some untyped pointer, it would almost certainly need to do integer arithmetic. And, that wouldn't be very convenient.

The other reason is that keeping the type information around allows compilers that are so inclined to warn you if you try to dereference pointers and make an incompatible assignment with an implicit cast. Consider the following example:

    char c = 'c';
    char *cp = &c;

    double d = 5.5;
    double *dp = &d;

    *cp = *dp;
  

But, of course, compilers aren't required to produce warnings. And, in this case, our compilers don't. What to say? Need more warning levels! But, with the type information available, a compiler theoretically could produce a warning here.

Pointers vs Arrays

Lot's of folks like to say that "Pointers and arrays are the same thing." And, we've surely seen that we can use pointers to access the elements of an array. And, we've even seen that we can use array notation upon a pointer, and pointer notation upon an array. Based on what we've seen, pointers and arrays sure do look interchangeable.

But, they are not. Check out this example:

  #include 

  int main () {

    int numbers[10];
    int numbers2[10];

    int *numberp;

    numberp = numbers;
    numbers = numbers2;

    return 0;
  }
  

Notice that pointers are assignable -- and array variables are not. Let's consider the reason for this. The compiler builds a table, the symbol table, that keeps track of every identifier. Among other things, it keeps the address and size.

When a program is actually compiled, each of the identifier names is replaced by the actual address. Indirection is often directly supported in assembly with "indirect" versions of the instructions that are designed to take the addresses of values rather than the values themselves. In environments where the processor does not support indirect instructions, the assembly that is generated loades the correct value from the address into the "register" directly. So, in either case, the variable's address is used instead of its identifier.

If we look at a pointer variable, it is a variable like any other. There is a chunk of space in memory. Data is stored in this space. The variable's name and address are associated within the symbol table.

But, an array is different. When it comes to statically allocated arrays, there is no pointer variable. The address of the 0th element of the array is directly associated with the array in the symbol table. There is no "pointer variable". The symbol table entry, itself, is, in effect, used as the pointer. One consequence of this is that, unlike a pointer being used to access an array, a static array doesn't have a pointer to reassign. The array's identifier is actually tied directly to the beginning of the array via the symbol table, rather than indirectly via a pointer variable, which like any other variable, can be reassigned.

Compilers people refer to "lvalues" and "rvalues". lvalues are places to hold things. rvalues are the things that you can stuff into those places. Think of the left and right side of an equals sign. The identifier associated with a statically allocated arrays has an rvalue, but no lvalue.

gdb

We went through a quick gdb example. The upshot is this. To enable debugging, use the "-g flag" to gcc. This tells gcc to include in the executible file, almost as a comment at the end, a mapping from each chunk of assembly back to the line of source code that generated it.

After recompiling with the -g flag, we ran our program in gdb. To do this for our example, named "example", we typed "gdb example" Once at the "gdb" prompt, we made use fo the following commands:

We observed that gdb printed things the "Right way" by using the type information, but that things could be cast to get it to print things differently. We also saw that gdb has no trouble with multiple files and can easily inspect arrays using either pointer or array notation.