CS211: Memory
Table of Contents
Introduction
Programmers never die
They just lose their memory — Desk tchotchke I own
We now know that data is a fundamental abstraction which is necessary for the completion of computing tasks. By definition, it would be absurd to conceive of performing computations if there was no data to perform computations on. Logically, considering our computers are physical devices which hold physical information, it would follow that if we need to perform computations with data it must be had from somewhere. We call that "somewhere" memory.
What is Memory?
One possible definition of memory, provided by David Patterson and John Hennessy in their classic introductory text Computer Organization and Design, goes as follows: "Memory is where the programs are kept when they are running; it also contains the data needed by the running programs." That is to say that memory is a location. It is some space within the components that make up a computer. The simple term "memory" itself is often ambiguous when devoid of context; as it turns out, there's many different kinds of memory and devices which act as computer memory. For example, there's volatile and non-volatile memory, main and secondary memory, virtual and physical memory, etc. Functionally, memory is anything that cannot be stored purely in the CPU—specifically within its registers. Although there are many registers on the average x86-64 chip, all of them combined could not store the entirety of gcc.
Memory Hierarchy
Again, when we refer to "memory" its often a shorthand for a wide variety of components inside of the computer. These components can be arranged to form a memory hierarchy:
+--------------------------------------------------+
| CPU |
| [VOLATILE] |
| +------------------+ |
| | Registers | <- Fastest, smallest |
| +------------------+ (~bytes) |
| | |
| +------------------+ |
| | L1 Cache | <- (~KB) |
| +------------------+ |
| | |
| +------------------+ |
| | L2 Cache | <- (~MB) |
| +------------------+ |
| | |
| +------------------+ |
| | L3 Cache | <- (~MB) |
| +------------------+ |
| | |
+-----------|--------------------------------------+
|
+------------------+
| Main Memory | <- (~GB) [VOLATILE]
| (RAM) |
+------------------+
|
- - - - - - - - - - - - - - - - - - - - - - - - - -
| [NON-VOLATILE]
+------------------+
| Secondary | <- Slowest, largest
| Storage (Disk) | (~TB)
+------------------+
The closer a component is to the CPU, the easier it is to retrieve information from it. Additionally, read/write speeds exist in an inverse relationship to cost and storage size. Very fast components, like registers, are as speedy as they are because they cannot store much information. This has to do with the physical make-up of memory devices (something that is outside the scope of this class to get into).
Additionally, there is a split between volatile and non-volatile memory. Volatile memory refers to memory components which lose their charge (data retention) after they are powered down. Dynamic random access memory, or DRAM, is one such example1—CPU registers and caches are another. Non-volatile memory is constructed in such a way that cells are capable of storing information long after the device is powered down. Non-volatile memory components, like solid-state drives (SSDs) and hard disk drives (HDDs), are the reason we can shut off our computers and turn them on again without losing precious files and programs.
Memory Semantics
What matters most to us as C programmers right now, is not so much the innerworkings of memory devices, but rather how the C runtime interacts with memory (how it is read, written, allocated, and deallocated). In the programming language community, we call that sort of behavior the memory semantics of a language. Luckily for us, C is about as "bare metal" as most high-level programming languages get, which means it doesn't take to much to describe its semantics. We can categorize data in our C runtime under two umbrellas: statically allocated memory and dynamically allocated memory.
Statically Allocated Memory
Statically allocated memory refers to memory that is available at the start of the program's runtime—meaning it has already been allocated by the time it has been compiled and linked into an executable.
Global Variables
Variables declared in the global scope must be allocated before the execution of a program. These variables must be reachable by any and all functions that reference them.
1: int g = 100; 2: 3: int foo(int a, int b){ 4: int x, y, val; 5: //.... 6: return val; 7: } 8: 9: int goo(int c, int d){ 10: int t, u, val; 11: //... 12: return val + g; 13: } 14: 15: int main(){ 16: goo(1,2); 17: }
In Listing 2 we see a global variable g declared at the top. We also see three functions defined: foo, goo, and main. Neither main nor foo uses g, but they could have. Since g was defined in the global scope, any function is capable of referencing it. This necessitates the compiler reserving space a priori for any location in the code that may want to reference it.
String Constants
Think back to our first program in class, hello_world.c, represented below:
1: #include <stdio.h> 2: int main(){ 3: printf("Hello, World!\n"); 4: }
I had mentioned that the characters contained within the quotation marks were called a string. The colloquial type refers to the "string" of characters contained within the phrase. It so happens that these characters are stored in what's called "read-only" memory within the executable file. Let's take a look at the assembly dump of the .c file (using gcc -O0 -fno-omit-frame-pointer -fno-stack-protector -S hello_world.c):
1: .file "hello_world.c" 2: .text 3: .section .rodata 4: .LC0: 5: .string "Hello, World!" 6: .text 7: .globl main 8: .type main, @function 9: main: 10: .LFB0: 11: .cfi_startproc 12: endbr64 13: pushq %rbp 14: .cfi_def_cfa_offset 16 15: .cfi_offset 6, -16 16: movq %rsp, %rbp 17: .cfi_def_cfa_register 6 18: leaq .LC0(%rip), %rdi 19: call puts@PLT 20: movl $0, %eax 21: popq %rbp 22: .cfi_def_cfa 7, 8 23: ret 24: .cfi_endproc 25: .LFE0: 26: .size main, .-main 27: .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0" 28: .section .note.GNU-stack,"",@progbits 29: .section .note.gnu.property,"a" 30: .align 8 31: .long 1f - 0f 32: .long 4f - 1f 33: .long 5 34: 0: 35: .string "GNU" 36: 1: 37: .align 8 38: .long 0xc0000002 39: .long 3f - 2f 40: 2: 41: .long 0x3 42: 3: 43: .align 8 44: 4:
We can see on Line 3 the assembly code defines a section called .rodata which stands for "read only data". Lines 5 and 6 define the string constant "Hello, World!" which is later referenced on Line 18 for the call to printf (again, printf is optimized away and replaced by puts). If I were to add some code which tries to alter the string directly, by replacing "Hello, World!" with "Jello, World!", we'll find it doesn't work as expected:
1: #include <stdio.h> 2: int main(){ 3: char * msg = "Hello, World!\n"; 4: msg[0] = 'J'; // Trying to reset the first character of the string 5: printf("%s", msg); 6: }
Now if I try to compile and run that:
josephraskind@stargazer:/tmp/mem$ gcc -O0 -fno-omit-frame-pointer -fno-stack-protector jello_world.c -o jello_world josephraskind@stargazer:/tmp/mem$ ./jello_world Segmentation fault (core dumped)
I get a strange error! This happened because I tried to edit, or write to, memory that was set to read only. We'll learn more about this when we discuss pointers.
static Keyword
This one is a bit of a subtle point which I've added for completeness.
Variables tagged with the static keyword variables take on the same behavior as global variables do regarding memory allocation, although their lexical scope remains within the block they were defined in. Here is an example:
1: #include <stdio.h> 2: 3: int foo(){ 4: static int x = 100; 5: x = x + 1; 6: return x; 7: } 8: 9: int main(){ 10: printf("1st call to foo(): %d\n", foo()); 11: printf("2nd call to foo(): %d\n", foo()); 12: }
Notice I have prefixed the static tag to my int declaration on Line 4. Now we can compile and run the code ():
josephraskind@stargazer:/tmp/mem$ gcc static.c -o static; ./static 1st call to foo(): 101 2nd call to foo(): 102
How is this possible?! Again, turning to assembly will help us out (gcc -O0 -fno-omit-frame-pointer -fno-stack-protector -S static.c):
1: .file "static.c" 2: .text 3: .globl foo 4: .type foo, @function 5: foo: 6: .LFB0: 7: .cfi_startproc 8: endbr64 9: pushq %rbp 10: .cfi_def_cfa_offset 16 11: .cfi_offset 6, -16 12: movq %rsp, %rbp 13: .cfi_def_cfa_register 6 14: movl x.2315(%rip), %eax 15: addl $1, %eax 16: movl %eax, x.2315(%rip) 17: movl x.2315(%rip), %eax 18: popq %rbp 19: .cfi_def_cfa 7, 8 20: ret 21: .cfi_endproc 22: .LFE0: 23: .size foo, .-foo 24: .section .rodata 25: .LC0: 26: .string "1st call to foo(): %d\n" 27: .LC1: 28: .string "2nd call to foo(): %d\n" 29: .text 30: .globl main 31: .type main, @function 32: main: 33: .LFB1: 34: .cfi_startproc 35: endbr64 36: pushq %rbp 37: .cfi_def_cfa_offset 16 38: .cfi_offset 6, -16 39: movq %rsp, %rbp 40: .cfi_def_cfa_register 6 41: movl $0, %eax 42: call foo 43: movl %eax, %esi 44: leaq .LC0(%rip), %rdi 45: movl $0, %eax 46: call printf@PLT 47: movl $0, %eax 48: call foo 49: movl %eax, %esi 50: leaq .LC1(%rip), %rdi 51: movl $0, %eax 52: call printf@PLT 53: movl $0, %eax 54: popq %rbp 55: .cfi_def_cfa 7, 8 56: ret 57: .cfi_endproc 58: .LFE1: 59: .size main, .-main 60: .data 61: .align 4 62: .type x.2315, @object 63: .size x.2315, 4 64: x.2315: 65: .long 100 66: .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0" 67: .section .note.GNU-stack,"",@progbits 68: .section .note.gnu.property,"a" 69: .align 8 70: .long 1f - 0f 71: .long 4f - 1f 72: .long 5 73: 0: 74: .string "GNU" 75: 1: 76: .align 8 77: .long 0xc0000002 78: .long 3f - 2f 79: 2: 80: .long 0x3 81: 3: 82: .align 8 83: 4:
We can see on Line 60 a new section called .data is demarcated. .data refers to read/write memory. Lines 64 and 65 provide the label and starting value associated with the static int x defined in Listing 7 (the label name is a "mangled" version of the original variable name to separate it from other potential static variables named x in other lexical scopes). Lines 14-16 show the increment and store operation broken up into three instructions (load, add, and store). Even though the variable was defined within the local function scope it has a program-long shelf life due to the static keyword.
Dynamically Allocated Memory
Dynamically allocated memory refers to memory that is not available at the start of the program's runtime—meaning it must be allocated and set during the program's execution. There are two runtime memory data structures used to handle dynamic memory: the stack and the heap.
Stack
The stack is the primary method of dynamic allocation that we have interacted with so far. Almost all of our variables have been placed and manipulated on the stack without our involvement. This is because the C compiler is responsible for manageing the stack—i.e. reserving space, pushing the frame pointer, popping unneeded values, etc. A short example to illustrate the point:
1: #include <stdio.h> 2: 3: void print_bool(int b){ 4: b? printf("True\n") : printf("False\n"); 5: } 6: 7: int is_negative(int n){ 8: return n < 0; 9: } 10: 11: int main(){ 12: int x = 1; 13: int y = -1; 14: print_bool(is_negative(x)); 15: print_bool(is_negative(y)); 16: }
Above we have a program which checks to see if two variables, x and y, are negative and then prints "True" or "False" depending on the outcome. We can take a look at the assembly genrated here (gcc -O0 -fno-omit-frame-pointer -fno-stack-protector -S is_negative.c):
1: .file "is_negative.c" 2: .text 3: .section .rodata 4: .LC0: 5: .string "True\n" 6: .LC1: 7: .string "False\n" 8: .text 9: .globl print_bool 10: .type print_bool, @function 11: print_bool: 12: .LFB0: 13: .cfi_startproc 14: endbr64 15: pushq %rbp 16: .cfi_def_cfa_offset 16 17: .cfi_offset 6, -16 18: movq %rsp, %rbp 19: .cfi_def_cfa_register 6 20: subq $16, %rsp 21: movl %edi, -4(%rbp) 22: cmpl $0, -4(%rbp) 23: je .L2 24: leaq .LC0(%rip), %rdi 25: movl $0, %eax 26: call printf@PLT 27: jmp .L4 28: .L2: 29: leaq .LC1(%rip), %rdi 30: movl $0, %eax 31: call printf@PLT 32: .L4: 33: nop 34: leave 35: .cfi_def_cfa 7, 8 36: ret 37: .cfi_endproc 38: .LFE0: 39: .size print_bool, .-print_bool 40: .globl is_negative 41: .type is_negative, @function 42: is_negative: 43: .LFB1: 44: .cfi_startproc 45: endbr64 46: pushq %rbp 47: .cfi_def_cfa_offset 16 48: .cfi_offset 6, -16 49: movq %rsp, %rbp 50: .cfi_def_cfa_register 6 51: movl %edi, -4(%rbp) 52: movl -4(%rbp), %eax 53: shrl $31, %eax 54: movzbl %al, %eax 55: popq %rbp 56: .cfi_def_cfa 7, 8 57: ret 58: .cfi_endproc 59: .LFE1: 60: .size is_negative, .-is_negative 61: .globl main 62: .type main, @function 63: main: 64: .LFB2: 65: .cfi_startproc 66: endbr64 67: pushq %rbp 68: .cfi_def_cfa_offset 16 69: .cfi_offset 6, -16 70: movq %rsp, %rbp 71: .cfi_def_cfa_register 6 72: subq $16, %rsp 73: movl $1, -4(%rbp) 74: movl $-1, -8(%rbp) 75: movl -4(%rbp), %eax 76: movl %eax, %edi 77: call is_negative 78: movl %eax, %edi 79: call print_bool 80: movl -8(%rbp), %eax 81: movl %eax, %edi 82: call is_negative 83: movl %eax, %edi 84: call print_bool 85: movl $0, %eax 86: leave 87: .cfi_def_cfa 7, 8 88: ret 89: .cfi_endproc
We detailed the calling convention in the lecture on functions, so I'll spare the overly detailed process here. Suffice it to say that it can be seen that %rbp, or the base/frame pointer register, is often being manipulated at the beginning of functions and anytime there should be an instruction where a local variable is being manipulated.
Heap
We'll learn more about the heap later when we discuss pointers. You can think of the heap as "the other stack", which it more or less is. The only difference is the heap is persistent between function calls, unlike the stack which is often riddled with pushes and pops that allow for old memory to be overwritten without programmer input.
Default Values
We know by now that variables do not need to be initialized with a value. By what are the values associated with an uninitialized variable? It turns out in C the behavior is undefined. If we declare a variable, say, int x;, we cannot guarantee the value that will contained in x. We can see that represented by compiling a simple C program,
1: int main(){ 2: int x; 3: int y = 10; 4: return x + y; 5: }
Which can be compiled down into assembly (gcc -O0 -fno-omit-frame-pointer -fno-stack-protector -S uninit.c):
1: main: 2: .LFB0: 3: .cfi_startproc 4: endbr64 5: pushq %rbp 6: .cfi_def_cfa_offset 16 7: .cfi_offset 6, -16 8: movq %rsp, %rbp 9: .cfi_def_cfa_register 6 10: movl $10, -4(%rbp) 11: movl -8(%rbp), %edx 12: movl -4(%rbp), %eax 13: addl %edx, %eax 14: popq %rbp 15: .cfi_def_cfa 7, 8 16: ret
We can see that on Line 10 a constant value of 10 was moved into -4(%rbp), the stack location for y. However, there's no equivalent movement for -8(%rbp) (the stack location for x). This means that x will store whatever value happens to be in that memory location at startup!
Exercises
- The lecture shows that trying to modify a string constant causes a segmentation fault as in Listing 6. Compile and run Listing 5 yourself and confirm the segfault. Then look at the assembly from Listing 9 and identify which section directive indicates that the string is stored in read-only memory. Why does the OS enforce this protection?
- Compile Listing 7 and run it. Then remove the
statickeyword from the declaration ofxand recompile. What changes in the output? Explain why in terms of storage duration. - Looking at the assembly in Listing 9, identify the label and section used to store the string constant "Hello, World!". Now write a C program with two string constants and compile it to assembly. How many
.rodataentries are produced? - The assembly for the
staticvariable in Listing 9 uses a mangled namex.2315instead of justx. Why does the compiler do this? What problem would arise if it used the plain namexinstead? - Write a C program with two functions where the first function declares a local variable, sets it to a known value, and returns. The second function then declares a local variable without initializing it and prints it. Call them in sequence from
main. Based on what you know about how the stack works from Listing 11, predict what value the uninitialized variable might hold and explain why. Does the output match your prediction? What does this tell you about relying on uninitialized variables? - The lecture states that the heap is "persistent between function calls" unlike the stack. Based on what you know about how the stack works from the assembly in Listing 11, explain in your own words why local variables do not persist between function calls.
- Write a C program with a
staticlocal variable inside a function that counts how many times that function has been called, printing the count each time. Call it five times frommain. Compile to assembly and identify where in the assembly the static variable is stored compared to the local variables. - The lecture states that global variables must be reachable by any function that references them. Write a program based on Listing 2 that has two functions both modifying the same global variable and print it after each modification. What does this demonstrate about the risks of using global variables?
- Looking at Listing 13, the assembly shows no instruction initializing
-8(%rbp)for the uninitialized variablex. Compile the same program withgcc -Walland record the warning produced. Then initializexto 0 and recompile to assembly — what new instruction appears? - The lecture distinguishes between statically and dynamically allocated memory. Categorize each of the following as static or dynamic and justify your answer:
- A
const intdeclared at the top of a file - A loop counter
int ideclared insidemain - A
static intdeclared inside a function - The string
"CS211"passed directly toprintf - A function argument
int x
- A