CS140 Lecture notes -- Types, Pointers

  • Jim Plank (with some modifications by Brad Vander Zanden)
  • Directory: ~cs140/www-home/notes/Types
  • Lecture notes: http://www.cs.utk.edu/~cs140/notes/Types

    Types in C

    In C, there are three kinds of types that variables can have -- scalars, aggregates, and pointers. Half of the game in getting things right in C is keeping yourself from being confused about types. This lecture tries to elaborate on this a little.

    Scalar Types

    There are 7 scalar types in C:
  • char -- 1 byte
  • short -- 2 bytes
  • int -- 4 bytes
  • long -- 4 bytes (8 on some systems)
  • float -- 4 bytes
  • double -- 8 bytes
  • (pointer -- 4 bytes (8 on some systems))

    These should all be familiar to you. You can declare a scalar variable in one of three places: As a global variable, as a procedure parameter, and as a local variable. For example, look at the program below in p1.c:

    (In this and all other lecture notes, you can copy the programs and the makefile into your own directory, and then compile them by using make. E.g. to make the program p1, you say ``make p1'').

    #include < stdio.h >
    
    int i;
    
    main(int argc, char **argv)
    {
      int j;
    
      j = argc;
      i = j;
      printf("%d\n", i);
    }
    
    There are three scalar int variables here -- i, j, and argc. I is a global variable. J is a local variable, and argc is a parameter. Scalars are pretty straightforward. You can pass them as parameters to procedures, and return them from procedures without worrying about anything going awry.

    Aggregate Types

    Arrays and structures are aggregate types in C. They are more complex than scalars. Arrays are meant to store a homogeneous collection of elements while structs are meant to store a heterogeneous collection of elements.

    You can statically declare an array as a global or local variable. I.e.:

    #include < stdio.h >
    
    char s1[15];
    
    main(int argc, char **argv)
    {
      char s2[4];
      
    }
    
    S1 is a global array of 15 chars and s2 is a local array of 4 chars.

    If an array has been statically declared, then you cannot assign it to another array, even if the two arrays have the same type and the same number of elements. For example, look at p2.c:

    #include < stdio.h >
    
    char s1[4];
    
    main(int argc, char **argv)
    {
      char s2[4];
      
      s2 = "Jim";
      s2 = s1;
    }
    
    The statement ``s2 = "Jim"'' is illegal in C, because s2 has been statically declared. The statement ``s2 = s1'' is similarly illegal in C, also because s2 has been statically declared. If you try to compile this program, gcc will give you an error:
    UNIX> gcc -o p2 p2.c
    p2.c: In function `main':
    p2.c:9: incompatible types in assignment
    p2.c:10: incompatible types in assignment
    
    UNIX>
    

    Like arrays, structs can be declared as either local variables or global variables. Unlike arrays, one struct can be assigned to another struct as long as the two struct have the same type. For example, check out paycheck.c:

    #include < stdio.h >
    
    struct paycheck {
        double gross_pay;
        double taxes;
    };
    
    struct paycheck brad;
    
    main(int argc, char **argv)
    {
        struct paycheck sue;
    
        brad.gross_pay = 50000.00;
        brad.taxes = brad.gross_pay * .3;
    
        sue = brad;
    
        printf("sue's gross_pay is $%8.2lf\n", sue.gross_pay);
        printf("sue's taxes are $%8.2lf\n", sue.taxes);
    }
    


    Pointers

    Pointers are where most people mess up in C. A pointer is simply a pointer to memory. Memory can be allocated in one of two ways -- by declaring variables, or by calling malloc(). Whenever memory has been allocated, you can set a pointer to it.

    You can view memory as one huge array of bytes (chars). This array has 2147483648 (or some other huge number) elements. Usually, we consider the indices to this array in hexidecimal. In other words, the array goes from 0x0 to 0x7fffffff.

    A pointer is simply an index of this array. Whenever we allocate x bytes of memory, we are reserving x contiguous elements from the memory array. If we set a pointer to these bytes, then that pointer will be the index of the first allocated byte in memory.

    For example, look at the following program (in p3.c):

    main()
    {
      int i;
      char j[14];
      int *ip;
      char *jp;
      int **ipp;
    
      ip = &i;
      ipp = &ip;
      jp = j; 
    
      printf("ip = 0x%x.  jp = 0x%x.  ipp = 0x%x\n", ip, jp, ipp);
      printf("&ip = 0x%x.  &jp = 0x%x.  &ipp = 0x%x\n", &ip, &jp, &ipp);
      printf("\n");
    
      i = 4;
      printf("i = %d\n", i);
      printf("ip = 0x%x, *ip = %d\n", ip, *ip);
      printf("ipp = 0x%x, *ipp = 0x%x, **ipp = %d\n", ipp, *ipp, **ipp);
      printf("\n");
    
      *ip = 3;
      printf("i = %d\n", i);
      printf("ip = 0x%x, *ip = %d\n", ip, *ip);
      printf("ipp = 0x%x, *ipp = 0x%x, **ipp = %d\n", ipp, *ipp, **ipp);
      printf("\n");
    
    }
    
    
    This program allocates one integer (i), an array of 14 characters (j), and three pointers (ip, ipp and jp). It then sets the pointers so that they point to the memory allocated for i, ip and j respectively. Next, it prints out the values of those pointers. We do this in hexidecimal with a `0x' in front of it. Why hexidecimal? Because it's the standard way to view pointers. Do you remember your hexidecimal? If not, go here.

    Note that even though we haven't set any values for i or j, we can still print their addresses.

    When we run p3 the first line is something like (note, these values will differ from machine to machine, so if you run p3, you will likely get different values. However, the interrelationship between these values should remain the same):

    ip = 0xefffe54c.  jp = 0xefffe538.  ipp = 0xefffe534
    
    These values are indices into the memory array. Can we tell anything from them? Well, they are large. They are multiples of 4. They are close to each other. Let's zoom into that region of memory (remember, memory is just a big array):
            ...   |              |
       0xefffe52c |              |
       0xefffe530 |              |
       0xefffe534 |     ip       |
       0xefffe538 | j[0] - j[3]  |
       0xefffe53c | j[4] - j[7]  |
       0xefffe540 | j[8] - j[11] |
       0xefffe544 | j[12] - j[13]|
       0xefffe548 |              |
       0xefffe54c |      i       |
            ...   |              |
    
    I'm drawing the memory as a bunch of 4-byte quantities. The fact that ip equals 0xefffe54c, means that the four bytes starting at 0xefffe54c are where the value of i is stored. Moreover, the fact that jp equals 0xefffe538 means that the fourteen bytes starting at 0xefffe538 are where j[0] through j[13] are stored. The fact that ipp equals 0xefffe534 means that the four bytes starting at 0xefffe534 are where the value of the pointer ip is stored. On our machines, pointers take 4 bytes.

    Note all of these facts are reflected in the little picture of memory. The next line prints the addresses of ip, jp and ipp:

    &ip = 0xefffe534.  &jp = 0xefffe530.  &ipp = 0xefffe52c
    
    The address of ip we knew already (it was the value of ipp). Now we also know the addresses of jp and ipp. Now, everything we know about this part of memory is drawn below:
            ...   |              |
       0xefffe52c |ipp=0xefffe534|
       0xefffe530 |jp= 0xefffe538|
       0xefffe534 |ip= 0xefffe54c|
       0xefffe538 | j[0] - j[3]  |
       0xefffe53c | j[4] - j[7]  |
       0xefffe540 | j[8] - j[11] |
       0xefffe544 | j[12] - j[13]|
       0xefffe548 |              |
       0xefffe54c |      i       |
            ...   |              |
    
    Stop and make sure you understand this far. I know you're probably a little confused, but once you understand that pointers are just indices to the big memory array, they become a little less mystifying.

    Now, the next thing that program p3 does is set i to four, and then print out i, ip, *ip, ipp, *ipp and **ipp:

    i = 4
    ip = 0xefffe54c, *ip = 4
    ipp = 0xefffe534, *ipp = 0xefffe54c, **ipp = 4
    
    So, memory now looks like
            ...   |              |
       0xefffe52c |ipp=0xefffe534|
       0xefffe530 |jp= 0xefffe538|
       0xefffe534 |ip=0xefffe54c |---\
       0xefffe538 | j[0] - j[3]  |   |
       0xefffe53c | j[4] - j[7]  |   |
       0xefffe540 | j[8] - j[11] |   |
       0xefffe544 | j[12] - j[13]|   |
       0xefffe548 |              |   |
       0xefffe54c |     i = 4    |<--/
            ...   |              |
    
    Note, I showed how ip ``points'' to i. Note also that *ipp equals ip, and thus that **ip equals 4.

    Now, finally we set *ip to 3. This makes memory look like:

            ...   |              |
       0xefffe52c |ipp=0xefffe534|
       0xefffe530 |jp= 0xefffe538|
       0xefffe534 |ip=0xefffe54c |---\
       0xefffe538 | j[0] - j[3]  |   |
       0xefffe53c | j[4] - j[7]  |   |
       0xefffe540 | j[8] - j[11] |   |
       0xefffe544 | j[12] - j[13]|   |
       0xefffe548 |              |   |
       0xefffe54c |     i = 3    |<--/
            ...   |              |
    
    Thus, we get the following printout:
    i = 3
    ip = 0xefffe54c, *ip = 3
    ipp = 0xefffe534, *ipp = 0xefffe54c, **ipp = 3
    
    Note, none of the pointer values have changed. However, i has been set to three.

    Other things: Note that in p3.c, I said ``jp = j'' and not ``jp = &j''. This is because when treated as an expression, an array is equivalent to a pointer. The only difference is that you cannot assign a value to an array variable. Thus, you can say ``jp = j'', but you cannot say ``j = jp''. Moreover, you cannot take the address of an array variable -- saying ``&j'' is illegal.

    Pointers are a little like scalars -- they too can be declared as globals, locals or parameters, and can be assigned values, passed as parameters, and returned from procedures. On our machines all pointers are 4 bytes. Thus, in p3.c, there are 30 bytes of local variables allocated in the main() procedure -- 4 for i, 14 for j, 4 for ip, 4 for jp, and 4 for ipp.


    Segmentation Violations and Bus Errors

    Memory can be viewed as a giant array of bytes. However, certain parts of this array are not accessible. For example, elements 0 to 0x1000 are inaccessible. When you try to access an inaccessible element, you generate a segmentation violation, and your program dumps core so that you can debug it. The reason element 0 is inaccessible is that it's a common bug to forget to initialize a pointer. When that happens, the pointer's value is zero, and when you try to dereference it, you'll generate a segmentation violation, which helps you find the bug.

    The program pa.c generates a segmentation violation by trying to dereference NULL:


    #include < stdio.h >
    
    main()
    {
      char *s;
    
      s = NULL;
    
      printf("%d\n", s[0]);
    }
    

    Whenever you access a scalar type, its value in memory must be aligned. What this means is that if the type is 4 bytes, its location in memory must start at a memory index that is a multiple of 4. For example, if i is an (int *), then if i is not a multiple of 4, dereferencing i will be an error. This error is manifested by a bus error and another core dump. For example, program pb.c makes the error of trying to dereference memory location 1 as an integer. This will generate a bus error (therefore bus errors have precedence over core dumps):
    main()
    {
      int *i;
    
      i = (int *) 1;
    
      printf("%d\n", *i);
    }
    

    Where does memory come from?

    Memory is given to your program by the operating system. Your program can get at memory in one of three ways:
    1. As a global variable.
    2. As a local variable or parameter in a procedure call.
    3. From malloc().
    Look at p1.c again:
    int i;
    
    main(int argc, char **argv)
    {
      int j;
    
      j = argc;
      i = j;
      printf("%d\n", i);
    }
    
    This program uses 16 bytes of memory: Now, there might be more memory referenced by argv. Don't worry about where that comes from.

    Global variables can be accessed anytime, anywhere. They are allocated by the operating system when the program starts. We will not use global variables in this class. The reason is that there is a place for global variables, but it isn't in the programs that you all will write. The only thing that global variables will do is make your code harder to read, and probably more buggy.

    What's an example of a reasonable global variable? When you say:

      fprintf(stderr, "something\n");
    
    stderr is a global variable. It is defined in stdio.h, which is why if you try to compile a program that uses stderr, but you don't include stdio.h, you'll get an error in compilation.

    The memory allocated for local variables and parameters can be accessed only while their defining procedure is running. This is convenient, because usually you don't want it otherwise. For example, look at pc.c:

    a(int n)
    {
      int total;
    
      total = 0;
      while (n > 0) {
        total += n;
        n--;
      }
      return total;
    }
        
      
    main(int argc, char **argv)
    {
      int i;
    
      i = a(3);
      printf("%d\n", i);
    }
    
    The memory allocated for total and n only exist while a() is executing. That makes sense.

    So what does it mean when I say: ``local variables and parameters can be accessed only while their defining procedure is running?'' Look at pd.c:

    
    char *read_word()
    {
      char s[200];
    
      if (scanf("%s", s) != 1) return NULL;
    
      return s;
    
    }
        
      
    main(int argc, char **argv)
    {
      char *strings[10];
      int i;
    
      for (i = 0; i < 10; i++) {
        strings[i] = read_word();
        if (strings[i] == NULL) exit(1);
      }
    
      for (i = 0; i < 10; i++) {
        printf("%d %s\n", i, strings[i]);
      }
    
    }
    
    Try running it -- does it do what you think? We'll discuss it next class.