pete > courses > CS 315 Fall 26 > lecture 05: pointers
Lecture 05: pointers
Goals
- use stack, heap, global, and static variables correctly
- use pointer operations: *, &
- effectively use pointers with structs
- perform pointer arithmetic
- explain the type of argv
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:
- it gives every process the illusion that there are way more available addresses than physical memory
- it makes every process think it’s the only process running and thus can use any address (with some caveats, to be seen later)
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
- global variable (global)
- local variable (stack)
- malloc (heap)
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)