pete > courses > CS 315 Fall 26 > lecture 05: pointers


Lecture 05: pointers

Goals


today is a bit of architecture review

specifically with the intent of solidifying your mental model of memory and pointers

because they can be really tricky

but if you take it slow and think carefully about the precise contents of the various pieces of memory involved, you should be okay

we’ll also begin to look at a few tools to help us debug memory- and pointer-related errors


to start off, the view of memory as presented in architecture is that it is essentially a massive array

we access (read or write) items in the array by providing their location in the array, which in this context we call an address (but it behaves exactly like the index of array parlance)

in modern machines, when we look at the data associated with a particular address, we find 1 byte (8 bits, also known as 1 octet)

for a given machine, every address stores the same amount of data—this parameter is referred to as the addressability

therefore, most modern machines are byte-addressable, meaning that each address refers to a single byte of memory


the total number of addresses is ostensibly determined by the amount of physical memory installed in the machine

in reality, the operating system plays two tricks:

the how and why of this will be reserved for Operating Systems (or a lecture late this semester after I’ve presented everything that needs to be presented)


therefore, what is a pointer?

it’s just a variable

whose value is an address

pointers are useful if we want to refer to some data by its location rather than its name

especially useful for large structs, which are expensive to pass around as values—much more efficient to pass around an address


there are two primary things we can do with a pointer variable

we can assign to it—that is, cause it to contain a particular address

or, assuming the pointer already has a value, we can go to that address and do stuff with the data there


assigning is easy, we just use the normal = operator

so this is legal code:

char *cp;
cp = 12;

we declare cp to be a pointer (the asterisk) and we tell the compiler that we expect a value of type char to be stored there

(when declaring a pointer, you must tell the compiler the type of thing expected to be found at the address)

(it is, however, possible to declare a pointer that points to a value of unspecified type by declaring it of type void *)

the second line above stores the value 12 in the pointer cp

implying that we expect to find a character at address 12


the operation of taking a pointer and getting the data it points to is called dereferencing

and is done using the dereference operator: *

following from the example above, this code prints the character found at address 12:

printf("the character at address 12 is %c\n", *cp);

you might ask yourself: how likely is it we would actually find a character at address 12?

or, really, at any arbitrary address

the answer is: so very incredibly not

there are 2^47 potentially-usable addresses on modern Intel machines

(yes, even though they use a 64-bit word size, which vocabulary word refers to the number of bits used to store an address)

and the overwhelmingly vast majority of programs don’t use anything remotely resembling a significant chunk of that amount of memory

you will, in fact, pretty much never see code that assigns an integer literal to a pointer


which begs the question: how can we make a pointer that points to something meaningful?

one way is the & operator

which, when applied to a variable, produces its address

consider this code in some arbitrary function:

char c;
char *cp;

cp = &c;

both variables c and cp live on the stack—meaning they are in memory, meaning their values can be located by address

the right-hand side of the final line is the address where the variable c is stored; the assignment puts that address in the variable cp

(the other way to get a pointer is from functions that themselves return pointers—more on those in a bit)


I mentioned the stack just now

you may recall that it is the region of memory that stores the execution state of the program

when a function is called, a stack frame (or activation record) is pushed on to the stack

this frame stores the values for that function’s local variable, parameters, return value, etc

when that function calls another function, a new frame is pushed, etc, etc

when a function returns, its frame is popped, and the next lower frame becomes the active one


what other regions of memory do you recall from architecture?

we’ve got global variables

the heap, used for dynamic allocation of memory

and instructions


we could write a program to tell us where all of these regions lie in memory

such a program might look like this: memory-regions.c

a few things to note: the "%p" format specifier to printf instructs it to print the value of the pointer itself (ie, the address) and not the thing at the address

the "(void *)" part is a cast: a message to the compiler that overrides its understanding of the type of the following thing

the %p format specifier requires a parameter of type void *, so the cast is telling the compiler not to worry, that this really is a value of type void *, and to shut up with the warnings already

the void * type specifically usually refers to a generic pointer: we know it’s an address but we don’t know what it points to

casts are often dangerous, but in this case they are just fine: pointers themselves are just addresses, the values themselves exist irrespective of the type of the thing they point to

in general, you should be very careful with casts and only use them when you are absolutely sure you need to override the compiler’s understanding of a value’s type


it is particularly important that you be able to distinguish between the meanings of these two lines:

printf("heap variable is stored at %p\n", (void *) &heap_variable);
printf("heap_variable points to %p\n", heap_variable);

the first is printing out where the variable heap_variable itself is stored: it’s a local variable to main, so we would expect it to live on the stack

the second is printing out the contents of this variable, which will be whatever malloc returned

recall that malloc is the function we use to allocate memory from the heap, so the contents of heap_variable (as printed by the second line) will be a memory address that lives in the heap region


the last two calls to printf are a bit strange: main and printf itself are functions, how can we print them?

recall, however, that functions are just collections of instructions in memory

when we call a function, we jump to its first instruction

thus, the symbol main is just a pointer to the first instruction of that function, and we can print it like any other pointer, likewise for printf

note that, while they are indeed both functions, they live very far apart in memory

we’ll see why in a few lectures


by running the program, we begin to see a view of how the various regions are laid out relative to each other in memory

the stack is at very large addresses

the heap is at middle-ish addresses

globals are just below that

and instructions are just below that


put another way, if you want memory inside your program, there are three ways we can get it

and each method gives it to us from a different place

why prefer one over the other?


global variables have extremely limited usefulness

they are declared outside of all functions and yet are accessible (both reading and writing) by all functions

this flexibility makes them easy to abuse, which is why they are usually frowned upon

we will, however, see a circumstance later in this course where they are helpful


local variables are declared within a function and are accessible (read/write) only by it

well, the name is usable only by that function, but while the function is active, that data is on the stack and can be used by any code that knows its address

this means that function a can call function b and pass it the address of one of its local variables and b can modify that variable within a’s stack frame

(this is precisely what stat does: you usually declare a struct stat as a local variable and pass its address to stat for it to fill in—this works because stat knows where to find it)

as noted before, when a function returns, its stack frame (and hence its local variable) conceptually disappear


which leads us to the heap, which allows for memory allocated outside the stack

this is useful because I might want memory to be allocated by some function to be used even after it returns

you’ve already seen an instance of this in assignment 2, where we want myopen to create (allocate memory for) a struct

but then we want that struct to be useful (eg, to pass to myread) even after myopen is done

we use the malloc function to request memory from the heap: its parameter is the number of bytes we want, and it returns the address of the first byte of an appropriately-sized chunk

when we are done using a piece of memory we got using malloc, we should call free on it to return it to the pool of allocatable memory (more detail when we talk about memory leaks)


here’s a silly little program to review the structure of the stack: stack.c

main is called first

its stack frame contains its parameters (argc, argv)

(conceptually, at least, though in practice these are passed in registers for efficiency reasons, more of an architecture question, not worth worrying about much here)

main calls a

this results in a new stack frame for a being pushed onto the stack

it contains space for the parameter x and the local variable tmp

a then calls b

which results in yet another stack frame being pushed, this time for b

it contains space for the parameter y and the different local variable tmp

when b is done, it returns and its stack frame is popped

then a returns and its stack frame is popped

finally, main returns and the stack is empty


this program demonstrates something you might be tempted to do with local variables: stack-variable.c

here’s the sequence of events: - main calls foo, which causes a stack frame for foo to be pushed - foo does its thing - foo returns, which causes that stack frame to be popped - then main calls bar, which causes a new stack frame for bar to be pushed in the exact same spot as the frame for foo used to be - bar does its thing - bar returns, which causes its stack frame to be popped

seems straightforward, but there’s a trick

note that foo returns a pointer… to a local variable, which lives in its stack frame

except when foo returns, its stack frame essentially no longer exists

and then when bar is called, its stack frame magically happens to live in the same memory as the previous stack frame for foo

so the variable n in main actually points into the stack frame for bar, even though it was returned by foo

tricksy! and dangerous

the compiler doesn’t like it, even without extra error checking:

$ gcc -o stack-variable stack-variable.c
stack-variable.c: In function ‘foo’:
stack-variable.c:29:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &y;
            ^~
stack-variable.c: In function ‘bar’:
stack-variable.c:39:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &z;
            ^~

and running it produces an error:

$ ./stack-variable
Segmentation fault (core dumped)

strictly speaking, this shouldn’t produce an error: the memory address is indeed valid

what we’re seeing is the result of protections put in place by the compiler to prevent doing this very bad, evil, not-good thing

(this program actually runs, though it produces strange results, when compiled with a different compiler)


when using the heap, quite often you will want to use it to store structs

(structs are C’s record type, allowing us to gather together a bunch of fields that all relate to a single data item; they are like classes, but without methods, only member variables)

here’s a quick example: struct.c

review for yourself the declaration of the struct

then note how we declare two variables: p and pptr

p is a struct on the stack

and pptr is a pointer on the stack (which we tell the compiler we expect to contain the address of a struct point)

which, in fact, it does, because we use malloc to get a chunk of memory sized exactly correctly to contain a struct point

the sizeof function returns the number of bytes needed to store a value or type

(it’s not really a function, but it’s okay to think of it like one; we’ll dispell this caveat in a few weeks)


to access the fields of a struct, we use the . operator, just as in Java or Python with objects

if we have a pointer to a struct, however, we first have to dereference it:

we use the * operator to convert a pointer value into the thing it’s pointing to

so this line here:

(*pptr).x = 142;

says "pptr is a pointer to a struct, go there and find the struct, now find the x field of that struct, and save the value 142 in it"

this pattern is so common, there’s a shorthand:

pptr->x = 142;

does the exact same thing (and is universally preferable)


on a similar note, you may at some point be tempted to do something like this:

struct foo *f;
f->field1 = 12;

why is this bad?

because f-> operator says "f contains an address; go there"

but we just declared f—it has no value!

furthermore, we didn’t actually declare any memory to hold a struct, we just declared a pointer to one!


since pointers are just addresses, and addresses are just numbers, you might be tempted to ask if we can perform arithmetic on them

the answer is yes: pointer-arithmetic.c

we declare a string, which in C is a chunk of memory identified by its first address and ending in a null byte

then we declare a character pointer and cause it to point to the first character of the string

then we print the data at that address

at that address plus 1, plus 2, plus 3, and plus 4

giving us the whole string, one character at a time


we could use this to walk through an array: more-pointer-arithmetic.c

but hold on: integers are 4 bytes, and we’re only adding 1 for each item

if we add 1, will we not get three bytes of the first integer and one byte of the second?

nope:

$ ./more-pointer-arithmetic
1
2
3
4
5

the reason is the compiler knows that ip points to an integer, so it assumes that, if we add 1 to it, we really want to point to the next integer and thus instead adds sizeof(int)

(and adding 2 actually adds 2*sizeof(int), etc)

this works no matter the size of the thing being pointed to, even if it’s a struct: printing-pointer-arithmetic.c


you may recall that we left off with using pointer arithmetic to access the elements of an array

this is actually a common technique to traverse arrays in general: array-traversal.c

recall that an array is just a chunk of memory that contains a bunch of similarly-typed values in sequence

it makes sense to identify this chunk of memory by its address

which is why we can assign an array (integer_array) to an integer pointer (ip)

thus, during the initialization step of the for loop, ip will point to the first integer in the array

the loop will iterate as long as the thing pointed to by ip is not zero (this is what the "*ip" part of the for loop means)

and after each iteration, ip will be incremented (by 4, because integers are 4 bytes long)

each iteration, we print out the integer currently being pointed to by ip


this works because the final element in the array is zero

in this case, the zero is not considered one of the elements per se, rather it just denotes that the array has ended

it is thus called a sentinel, because it sits imposingly at the end and makes sure nobody goes past

yes, this also means that we can’t store the value zero as an element of this array, but that’s not a problem in common uses of this…


which leads us to argv: an array of character pointers that describes the command-line arguments provided to a program

so if the command-line arguments are "hello" and "world", this means that the string "hello" will be somewhere in memory and the first element of the argv array will be the address of the "h" in "hello", and the second element will be the address of the "w" in "world"

this means we can step through this array using the same pointer magic as before: argv.c

this time, though, since our array consists of character pointers, we need our iterator variable to itself be a pointer to a character pointer: a char **

everything else follows logically (but make sure to work through this one on your own: the usage is highly idiomatic, so you will run into similar stuff in production code)

Last modified: