Please read this page about taking my exams!
Exam format
- When/where
- During class, here, like normal
- 75 minutes
- it is not going to be “too long to finish”
- Length
- 3 sheets of paper, double-sided
- there are A Number of Questions and I cannot tell you how many because it is not a useful thing to tell you because they are all different kinds and sizes.
- But I will say that I tend to give many, smaller questions instead of a few huge ones.
- Topic point distribution
- More credit for earlier topics
- Less credit for more recent ones
- More credit for things I expect you to know because of your experience (labs, projects)
- Only on lectures 1 through 9 inclusive
- Kinds of questions
- A few multiple choice and/or “pick n“ (but not many)
- Some fill in the blanks
- mostly for vocabulary
- or things that I want you to be able to recognize, even if you don’t know the details
- Several short answer questions
- again, read that page above about answering short answer questions!!
- No writing code from scratch, but:
- tracing (reading code and saying what it does)
- debugging (spot the mistake)
Things people asked about in the reviews
This is a list of what people asked about. The exam may have other topics not listed, and some of these topics may not appear on the exam.
Pointers
- pointer types look like
T*
and are read right-to-left: “a pointer to aT
” - pointers are created, confusingly, with the address-of (ampersand) operator:
&var
- for any
T
, ifvar
is of typeT
, then&var
is of typeT*
- so using
&
on anint
gives you anint*
; using it on anint*
gives you anint**
etc.
- for any
- when you assign a pointer variable, you are changing where it points
- if you want to change the value that it points to, you use the value-at, or dereference operator (asterisk)
- DEREFERENCE means to FOLLOW THE ARROW (i.e. access the value at the other end of the arrow - the value that the pointer is pointing to)
- for any pointer
p
of typeT*
,*p
is of typeT
- the dereference operator “strips a star off” - so if you have an
int* p
, then*p
is of typeint
. you can get anint
out of*p
or put anint
into*p
- there are two other “hidden” dereference operators in C:
p[n] == *(p + n)
(array indexing is just pointer arithmetic plus a dereference)p->f == (*p).f
(pointer-to-struct field access with->
is just a dereference plus.
)- all three of these -
*p
,p[n]
,p->f
- can cause UB, segfaults, etc. ifp
is pointing Somewhere Bad
- segmentation faults can happen when you DEREFERENCE an invalid pointer.
- an INVALID POINTER is one which is not pointing to a live piece of memory
NULL
is not pointing to a live piece of memory by definitionNULL
is actually memory address 0.
- (not needed for exam but: only a very very tiny fraction of memory addresses are valid. MOST of them are invalid and will cause segfaults. because VIRTUAL MEMORY)
example:
int x = 10;
int y = 20;
int p = &x; // p points to x
// ________
// x | 10 |<--+
// |------| |
// y | 20 | |
// |------| |
// p | --|---+
// |------|
printf("%d\n", *p); // "print the value-at p" - prints 10, cause that's what p points to
p = &y; // changes where p points; now it points to y
// ________
// x | 10 |
// |------|
// y | 20 |<--+
// |------| |
// p | --|---+
// |------|
(*p)++; // increments the value that p points to; increments y to 21
// ________
// x | 10 |
// |------|
// y | 21 |<--+
// |------| |
// p | --|---+
// |------|
printf("%d\n", y); // prints 21
- Const pointers (e.g.
const char*
) declare a pointer where you can read the values that the pointer points to, but attempting to write to those values will give a compiler error.- e.g. if you have a
const char* s
, then:printf("%c\n", s[0]);
is fine and good! you can read the characters out of the strings[0] = 'x';
is a compiler error because you’re not allowed to change the data at the other end of the “arrow”
- typically we use const pointers as arguments to functions where we want to promise that the function won’t change the thing that was passed in.
- e.g. the standard library
strcmp
function takes twoconst char*
s - it will read the characters to do the comparison, but it will never change either. - or e.g.
strcpy(char* dst, const char* src)
-dst
is achar*
, so it will write to the string that you pass in for the destination; butsrc
is aconst char*
so it will only read from that string.
- e.g. the standard library
- e.g. if you have a
- Pointers can point to many places.
- There are like four main areas of memory accessible to your program:
- the stack, where locals live,
- the heap, where
malloc()
allocates memory, - the static data segment, where globals live, and
- the read-only data segment, where constants live (e.g. string literals).
- technically this is also part of the static data segment so you could think of there being only 3 areas
- pointers can point to any of these places, and more.
- example code:
- There are like four main areas of memory accessible to your program:
int globby = 10; // in the static data segment (.text)
int main() {
int local = 20; // on the stack
int* p = &local; // p is pointing to a stack variable.
printf("%d\n", *p); // prints 20 (remember *p means "value at p", the value it's pointing to)
p = &globby; // now p points to a global variable.
printf("%d\n", *p); // prints 10
p = malloc(sizeof(int)); // now p points to a piece of heap memory.
*p = 30;
printf("%d\n", *p); // prints 30
free(p);
p = NULL; // now p points to NOTHING!!
// printf("%d\n", *p); // crashes with a segfault if you uncomment it
p = &local + 10; // now p points to ????????, not NULL but not valid
// &local + 10 means "an address 40 bytes after the address of local in memory" (see below)
// printf("%d\n", *p); // undefined behavior. might crash, might not, who knows
// s points to the string literal "hello, world",
// which is in the read-only data segment (.rodata)
char* s = "hello, world";
}
void*
is not a null pointer, it is a “generic” pointer- in the same way that in Java, you can make an
Object
variable which can point to any class instance type… void*
can point to any kind of memory address.- but unlike Java, there is nothing in C to tell you what a
void*
is really pointing at. it’s all up to you, the programmer, to know. - we see
void*
pop up in generic functions in C (which we’ll see more of in the latter part of the course) - we also see it in memory allocation stuff - e.g.
malloc()
returns avoid*
because it has no idea what you’re going to make that pointer point to. an array of ints? a single struct? who knows.
- in the same way that in Java, you can make an
Arrays
- C doesn’t really have arrays.
- you can declare arrays e.g.
int arr[10];
- and that array holds 10 items
- and you can access it as
arr[0]
thruarr[9]
- BUT…
- the array has NO concept of how long it is - it’s up to the programmer to remember
- when you pass this array to another function, it is NOT copied - it is turned into a POINTER! passed by reference
- if you have an array, and call
f(arr)
, it passes the address ofarr
tof
- if you have an array, and call
- ALSO…
p[n]
is not really an “array access” operator in Cp[n]
is just shorthand for*(p + n)
- so you can do arr[-5] or arr[200] and it won’t complain
sizeof()
can tell you how many BYTES an array takes up…- as long as you are in the same function where the array was declared
- also if you want the length, you have to divide by the size of one item
- HOWEVER using
sizeof()
on a pointer (e.g.char*
) will ALWAYS give you the same number: 8 (on thoth)- 32-bit systems would give you 4
- some 64-bit system configurations can give you 4 too… aaaaaaaaaaaaaaa
Memory Regions
- the stack is a region of memory that holds ACTIVATION RECORDS, one for each function call. ARs contain:
- the return address
- all local variables
- every “activation” of a function gets its own AR
- this also applies to recursive functions - each recursion gets its own AR with its own copies of the same local variables
- the heap is a different region of memory which we access by allocating with malloc() and deallocating with free()
- the main differences between the stack and the heap are:
- SIZE: the heap is WAY WAY WAY BIGGER than the stack
- stack is usually ~4-16MB, heap can be several GB or TB in size (practically unlimited)
- LIFETIME: everything on the stack has a lifetime that is tied to the function that allocated it; whereas the heap leaves the lifetime of memory to the programmer - they decide when to call free()
- in addition, the heap lets us make more-than-1-dimensional data structures
- wheras the stack is linear by nature
- SIZE: the heap is WAY WAY WAY BIGGER than the stack
- the static data segment is where:
- global variables (those declared outside any function) live
- and also, the read-only portion of the static data segment is where certain constants live (e.g. “double quoted strings”)
Undefined Behavior (UB)
- C is specified in kind of a weird way. there is a small set of things that are guaranteed to work correctly every time, and anything outside that set is considered undefined behavior (UB)
- an operation that causes UB can do different things depending on…
- which OS you’re using
- which version of that OS you’re using
- which compiler you’re using, and which version of it
- which flags you pass to the compiler
- which CPU architecture you use, which version, which brand, etc. etc. etc.
- randomly, based on how the OS lays out your program’s memory space when running your program
- many (but by no means all) instances of UB occur when dereferencing a pointer (
*p
,p[n]
,p->x
)- remember that there are valid pointers (which point to valid areas of memory); NULL pointers (which point to memory address 0); and invalid pointers (which aren’t NULL, but don’t point to valid memory areas either)
- if you do
*p
to get the value at an invalid pointer, it could…- crash (segfault, alignment error, bus error, etc)
- appear to work properly
- give you some arbitrary value
- give you some secret value that you shouldn’t have access to
- if you do
*p = x
to set the value at an invalid pointer, it could…- crash (segfault, alignment error, bus error, etc)
- appear to work properly, by changing some part of memory that is miraculously unused by anything else
- change some variable that it shouldn’t be possible to change
- mess up the activation records for one or more functions, causing erratic behavior or a crash later on
- mess up the data structures of the heap allocator, causing erratic behavior or a crash on the next malloc/free
Pointer arithmetic (you should def know this)
- again,
p[n]
is shorthand for*(p + n)
- but
p + n
is weird.p
is a pointer (any pointer), andn
is an integer - it calculates an address by:
- starting at
p
- implicitly multiplying
n
bysizeof(*p)
(the size of one “thing” thatp
points to) - adding that to
p
- starting at
- for example if you have
double* p
pointing at some array of doubles…p + 0
is the address of item 0 of the array.p + 0 == p
, cause duhp + 0
is also the exact same thing as&p[0]
- “the address of item 0 of the array p”
p + 1
is an address 8 bytes afterp
, which is item 1 of the array- because
sizeof(*p) == sizeof(double) == 8
, and8 x 1 = 8
- because
p + 2
is an address 16 bytes afterp
, because8 x 2 = 16
- that implicit multiplying step is called “scaling” and can trip you up on project 2!
- if you have a
Header* h
and you add e.g.sizeof(Header) + size
to it… - well,
sizeof(*h) == sizeof(Header)
, andn == sizeof(Header) + size
here, so… - you will actually be adding
sizeof(Header) * (sizeof(Header) + size)
bytes to the address! - this is why I gave you
PTR_ADD_BYTES(p, offset)
! it adds a number of bytes top
without the scaling.
- if you have a
Passing arguments by value versus by reference
- passing arguments by value is the “normal” way we pass them.
- when you call a function and pass by value, it copies the values into the arguments of the callee (and arguments are just local variables, so passing arguments is like assigning into the argument variables).
void my_function(int x) { // passing by VALUE (or "by COPY")
// this x is different from the x in main.
// modifying it only affects this variable, not main's.
x = 10;
printf("%d\n", x); // prints 10
}
int main() {
int x = 20;
my_function(x); // this *copies* the value 20 into my_function.
printf("%d\n", x); // prints 20
return 0;
}
- passing arguments by reference means giving the callee a pointer to a variable, which allows the callee to change a caller’s variable.
- essentially the caller is letting the callee “borrow” the variable for a bit.
- the pointer variable itself is still local to the callee, but the thing it points to belongs to someone else.
void my_function(int* p) { // passing by REFERENCE
*p = 10; // dereferencing - changes main's x!
printf("%d\n", *p); // prints 10
}
int main() {
int x = 20;
my_function(&x); // &T ==> T*
printf("%d\n", x); // prints 10!
return 0;
}
Returning pointers to locals (why it’s bad)
- Before every function starts running, it pushes an activation record onto the stack, which contains all of its local variables.
- This is why you can get the addresses of locals - because they are physically in memory, on the stack
- Before every function returns (stops running), it pops that AR back off
- At any given time, the stack pointer (
sp
) is pointing to the most-recently-pushed AR - Everything above the stack pointer is ARs that belong to currently-executing (or currently-waiting) functions
- Everything below the stack pointer is memory that is technically accessible on most implementations but which you should not access in any way because it is UB - it COULD crash, give you garbage, give you the right value…
- This is why returning pointers to locals (including local arrays) is bad. e.g.
int* my_function() {
int x = 500;
// have to do this to trick gcc into compiling
int* p = &x;
return p;
}
void another_function() {
// ooh it has variables
int a = 10, b = 20, c = 30;
}
int main() {
int* p = my_function();
// at this point, p points to a region of the stack
// that is BELOW the sp. if you printed out *p now,
// it would *likely* print out 500, but you are not
// guaranteed *anything* about the validity of doing
// it; it is UB.
// if we then call another function...
another_function()
// ...now we have *no* idea what this will print,
// because another_function reused the stack space
// that p is pointing to.
printf("%d\n", *p);
return 0;
}
Struct padding
typedef struct {
int x; // 4 bytes
char c; // 1 byte
double d; // 8 bytes
} MyStruct;
// if we print out sizeof(MyStruct) you might expect it to be 13, but it's actually 16.
- you do not need to know the details of the rules that the compiler uses to insert struct padding.
- if you are curious: every field must appear at an offset that is a multiple of its alignment (which is actually different from its
sizeof
but I don’t wanna get into it); and the entire struct’ssizeof
must be a multiple of the maximum alignment of all of its fields.
- if you are curious: every field must appear at an offset that is a multiple of its alignment (which is actually different from its
- but you do need to know why padding exists and to be careful about it
- padding exists to preserve the alignment of the fields
- alignment means a value that is
n
bytes long must exist at an address that is a multiple ofn
- like, the actual numerical address must be a multiple of
n
- 4-byte values can only exist at addresses that are multiples of 4
- so the address in hex ends in
0, 4, 8,
orC
- so the address in hex ends in
- 8-byte values (like
double
) can only exist at addresses that are multiples of 8- so the address in hex ends in
0
or8
- so the address in hex ends in
- like, the actual numerical address must be a multiple of
- alignment is important because some platforms crash your program if you don’t respect it (e.g. MIPS), and on other platforms, there can be a performance penalty for breaking alignment (e.g. x86)
- the other annoying thing is that different platforms have different rules about alignment, and therefore different C compilers can put different amounts of padding in your structs when compiling the same code on different compilers/computers
- this means that
sizeof(MyStruct)
can be wildly different on different platforms! - therefore you have to be extremely careful about e.g. writing and reading structs to and from files or sending them over networks
- only in some very specific cases (e.g. proj1 where you had the
Pixel
struct) can you safely do it, because there’s no way for the layout to be different on any platform- I think. I’m like 99% sure. lol.
- this means that
Struct field access
- when you declare a struct variable like
Point p;
- then you access fields with
.
:p.x
,p.y
etc. - that’s because declaring the struct variable like that makes an entire copy of that struct right there, e.g. for locals, it puts that struct on the stack
- then you access fields with
- if you have a pointer to a struct, like
Point* p = malloc(sizeof(Point));
, then:- you access fields with
->
:p->x
,p->y
- this is because
Point* p
puts a pointer on the stack, and to actually get to the fields, you have to “follow the arrow” to where the struct actually is.
- you access fields with
enum
- a way of declaring a collection of (typically) related integer constants
- they just declare constants. that’s it.
- the underlying type of an enum is implementation-defined
- which means that different compilers can choose different underlying types for that enum depending on the values that are in it
- e.g. on gcc - if all the values are >= 0, the underlying type is unsigned; otherwise, it’s signed
- and that value may be a char, or a short, or an int, or a long
- and you don’t know which
- enums are declared and used very similarly to structs - they have a “tag name” which you would refer to as
enum Tag
, and they are oftentypedef
ed to avoid having to use theenum
keyword everywhere:
typedef enum {
// by default, enum values are integers starting at 0 and increasing.
// so A == 0, B == 1, C == 2
// but you can set them to whatever you want, by writing:
// A = 5, B = -17, C = 494
A, B, C
} E; // E is now a typedef for whatever underlying integer type the compiler chose for this enum
int main(int argc, char const *argv[])
{
E e = A; // there is no namespace, you don't write E.A, just A
e = B;
e = C;
// the compiler doesn't prevent you from doing this, but it's not good.
// on gcc, E is given an unsigned type, and this line actually puts 4294967295
// into e!
e = -1;
return 0;
}
typedef
- lets you declare a type alias: shorter name for a longer type
typedef int X;
makesX
act as if it isint
- so you can declare
X main(X argc, char** argv)
- so you can declare
- typically used for long/ugly types
- e.g.
typedef unsigned int uint;
- or
typedef struct { int x; int y; } Point;
- so now you can declare
Point p;
instead ofstruct Point p;
- so now you can declare
typedef int (*FP)(int, int);
haha lol
- e.g.
Using the heap in C
- C makes the programmer manage heap memory. this is tricky.
- here are some rules for using the heap:
- you should check if
malloc
returnsNULL
(meaning out of memory)- for many programs it might just mean printing an error message and quitting.
- tho in quick little programs, not checking is probably fine because you’ll just get a segfault on the first line that accesses the pointer anyway, and the likelihood of a small program running out of memory is pretty slim
- you must call
free
on everything youmalloc
ed- though when the program exits, this is essentially done for you, so for short-running programs it may be fine to not call it
- you must not call
free
more than once on the same pointer- cause this will corrupt the heap (see below)
- you must not access heap memory that has been freed
- basically for the same reasons as returning pointers to locals - that memory is no longer alive! you don’t own it anymore!
- you should check if
- if you don’t
free
a piece of heap memory, and you lose all the pointers to it, that is a memory leak: neither the user program nor the heap allocator know that that memory is done being used, so it sticks around taking up space “forever”- well, not forever. just until the program ends. that’s the only way to free leaked memory.
The heap allocator and how it works
- you haven’t yet started on project 2, so I don’t expect you to know all the low-level concrete details of managing the heap (e.g. the exact pointer arithmetic calculations needed to put a header in the middle of an existing block when splitting, or the sequence of operations needed to link/unlinke a node in a doubly-linked list)
- and those are just implementation details anyway
- but I do expect you to know what the heap allocator is, what it does, what its responsiblities are, and the data structure we use to represent the heap (at least, the one that we learned about… there are others)
- the heap allocator is the part of the standard library that implements
malloc()
andfree()
- its responsibilities are:
- to keep track of which regions of the heap memory are used and which are free for reuse
- to allocate memory for the user when requested, either by reusing some free memory, or by asking the OS for more heap
- to free memory for the user when requested, by marking that memory free for reuse in a future allocation
- the data structure we learned about managing the heap is a doubly-linked list
- each region of the heap is a block that consists of a header (small, fixed size, used by the heap allocator) and the data (variable size, used by the user)
- the entire heap is a contiguous list of blocks, linked together into a doubly-linked list
- each block knows if it’s used or free, and how many bytes it is - this satisfies responsibility 1 above
- to allocate (responsibility 2):
- the allocator looks for a block to reuse with some algorithm (see below)
- if it found a reusable block, it marks it used and gives the user a pointer to the data part. (see below for splitting)
- if it didn’t, it asks the OS for more heap, appends that new block to the end of the heap, and gives the user that
- to deallocate (responsibility 3):
- the allocator marks the block as free.
- that’s all it has to do, but for better performance, see below.
- fragmentation
- fragmentation in general is “free space that can’t be used for some reason.”
- fragmentation is not good, because it makes our programs take up more heap memory than they should. so although it’s not one of the core requirements of the allocator, avoiding or reducing fragmentation is a nice goal
- external fragmentation is free blocks on the heap between used blocks, that are too small to be useful for most cases.
- e.g. if you have a bunch of tiny (8-32 B) free blocks scattered all over the heap between used blocks, they can really add up if you have thousands or millions of allocations
- internal fragmentation is wasted space within used blocks. “overallocation.”
- e.g. the user asked for 150 bytes and you marked a 180B block as used and gave it to them.
- yes, you satisfied the contract (gave them ≥150B) but…
- they’re not using the 30B at the end of the block, and you can’t give that to anyone else, either.
- fragmentation in general is “free space that can’t be used for some reason.”
- reuse selection algorithms
- when the allocator is looking for a block to reuse, there are a number of algorithms that can be used.
- first-fit: reuse the first block on the heap whose size is >= requested.
- next-fit: remember the last-allocated block. instead of starting at the start of the heap, you start looking after that block. if you get to the end of the heap, you wrap around to the beginning of the heap and keep looking. other than that, same as first-fit: you reuse the first block that you find whose size is >= requested.
- best-fit: reuse the *smallest free block on the heap whose size is >= requested.
- worst-fit: reuse the biggest free block on the heap whose size is >= requested.
- quick-fit: instead of keeping one list of blocks, we keep several lists of blocks, categorized by size. this way, we only consider a few blocks for each allocation instead of the entire heap
- splitting
- if a block is selected for reuse, it may be beneficial to split it into two smaller blocks
- e.g. if the block selected is 1000 bytes, and the user only asked for 100, then giving them the entire 1000 byte block would be wasting 900 bytes to internal fragmentation
- conceptually splitting is simple: just cut the block into two parts, give the user one part, and keep the other part as a smaller but still free block. that’s all I care about you knowing for the exam.
- if a block is selected for reuse, it may be beneficial to split it into two smaller blocks
- coalescing
- the opposite of splitting.
- when the user frees a block, it may be next to other freed block(s).
- in that case, it makes sense to coalesce or merge the adjacent free blocks into a single, larger free block.
- because larger blocks are easier to reuse.
- conceptually coalescing is simple: you just remove the boundaries between any adjacent free blocks, giving you a single, larger free block.
Other Heap-related Stuff
- heap compaction
- lets us eliminate external fragmentation (free space between used blocks that’s too small to be useful) BY “smooshing” all the used blocks to one end of the heap with no free space between them.
- conceptually similar to e.g. making all the cars park on the street close to each other, or shoving all the books to one side of a bookshelf
- BUT in order to enable this, you must make some big concessions in the design of your programming language:
- EVERY SINGLE POINTER IN THE PROGRAM must point to an entry in a list of heap pointers - essentially they become doubly-indirected
- NEVER allow a direct pointer to the heap
- you also cannot allow pointers to “interiors” of objects (e.g. in the middle of a struct or array)
- EVERY SINGLE POINTER IN THE PROGRAM must point to an entry in a list of heap pointers - essentially they become doubly-indirected
- not possible to do in C except through EXTREME discipline (and possibly custom tooling to check program correctness)
- but other languages (like Java) have been designed from the start to do this!
- lets us eliminate external fragmentation (free space between used blocks that’s too small to be useful) BY “smooshing” all the used blocks to one end of the heap with no free space between them.
- memory pooling
- if you know IN ADVANCE the kinds of memory allocations your program will be doing, you can do memory pooling:
- allocate a “pool” of memory in advance (like at the start of the program)
- slice uniformly-sized allocations off that pool very quickly
- typically used in programs where you have to very quickly allocate/deallocate thousands or millions of objects of the same size over and over and over.
- downside is: you need to know in advance!
- so this is harder to use in general-purpose allocators like malloc/free
- but it is a very useful tool to have in your pocket for special-purpose allocators in your own program
- if you know IN ADVANCE the kinds of memory allocations your program will be doing, you can do memory pooling:
Scope, Lifetime, Ownership
- scope is “where a name can be seen
- in most C-like languages, local variable scope lasts from the declaration until the enclosing close-brace
}
- in most C-like languages, local variable scope lasts from the declaration until the enclosing close-brace
- lifetime is the span of time from when a piece of memory is allocated to when it’s deallocated
- the lifetime of local variables is from the beginning of a function (when the AR is pushed) to the end of the function (when the AR is popped)
- the lifetime of heap memory is from when it is
malloc()
ed until when it isfree()
d.- the programmer controls the lifetime of heap memory.
- remember that the lifetime of a piece of heap memory is not the same as the lifetime(s) of the variable(s) that point to it
- e.g. if I have
int* p = malloc(10);
as a local variable,p
’s lifetime is like any other local variable - deallocated at the end of the function. but the lifetime of the memory thatp
points to only ends when I callfree(p)
- e.g. if I have
- ownership is about who decides when it’s okay to deallocate (i.e. “end the lifetime”) of some piece of memory.
- globals are owned by the program. they are allocated when the program begins running (before
main
) and are deallocated when the program exits (aftermain
). - locals are owned by their function. when the function returns, they’re no longer needed, so it’s okay to deallocate them by popping them off the stack.
- in C, heap memory is owned by… you. the programmer. you are responsible for deciding when it’s okay to deallocate every piece of heap memory.
- sometimes it’s really easy and straightforward
- many times it’s kind of fuzzy…
- sometimes it’s extremely hard to know when it’s okay.
- in GC’ed (garbage collected) languages like Java and Python, heap memory is owned by the GC.
- it uses Fun Graph Algorithms to determine when heap memory is unreachable by the user program, and anything unreachable is safe to deallocate (because there’s no way for the user program to ever use it again!)
- globals are owned by the program. they are allocated when the program begins running (before