aum Posted March 24, 2023 Share Posted March 24, 2023 A look at how C allows you to handle memory No not that memory management. Collection of old instant photos with trips - Lisa Fotios If you’ve never worked in a non-garbage collected language, then you’ve probably never had to worry about managing memory. I know when I heard the term I couldn’t even really conjure in my mind what that looked like. At its core, memory management is about using the tools of a language to work with memory addresses, reserve additional memory, and manipulate memory at specific locations. I’d like to demystify what that means by taking a look at a couple of examples. What’s a Pointer? If you write in Python or a similar language, you might create a variable that looks like this. my_var = 10 in C it would like this. int my_var = 10; In C, not only do you need to declare the end of the statement with a semi-colon, but you also need to declare the type of the variable. But other than those two things, it means exactly the same thing as it would in Python. C has one more trick up its sleeve though, Pointers. Pointers are integral to the memory management process, and they are actually quite simple. Pointers are just an address in memory on your computer. A pointer to my_var would look like this… int* pointer_my_var = &my_var; /* print to the console the value of our pointer */ printf("The value of the pointer to my_var is %p\n", pointer_my_var); The & is saying, get me the address of the variable my_var. int* Lets us know that the variable is a pointer. When we print out the value of pointer_my_var using printf we get the output… The value of the pointer to my_var is 000000000061FE14 What is this strange string of characters? Why, it’s a memory address! The next question you might have is, “what is a memory address?” and that is a good question. You can think of memory as a long street full of houses. Each house has an address that is used to refer to it, and you can fill the house with stuff. Memory addresses work the same way. When you have one you are referring to a specific part of the memory. Continuing with the metaphor. my_var is like the house. It stores the value 10. pointer_my_var is like the house’s address. You might be wondering, “Can I get the value that is stored at the memory address in pointer_my_var”? The answer to that would be a resounding yes. It requires you to dereference the pointer like so. /* Dereferencing the pointer with */ printf("The value stored at pointer_my_var is %d", *pointer_my_var); /* Output of the print statement */ The value stored at pointer_my_var is 10 Unfortunately, the * in the *pointer_my_var syntax is pulling double duty here as it is used both to dereference a pointer as well as declare a pointer. This is one of the reasons why pointers can be confusing. Now that we know how we can create a pointer, the next question might be, “What do we do with it?” Have you ever tried printing out a function in Python? def square(x): return x * x func = square print(f"Print the square function: {square}") print(f"Print the func variable: {func}") If you did, you’d noticed this strange output. Print the square function: <function square at 0x0000022D579804A0> Print the func variable: <function square at 0x0000022D579804A0> If you see this and think, that looks suspiciously like a memory address you’d be correct! And the output is the same whether we print the square function or the func variable that square is saved to. This turns out to be a powerful feature and allows Python to have first class functions. When a language has first class functions, you can pass a function as an argument to another function, much like you’d pass an integer or string. Using a simple calculator function as an example… # 4 function calculator # An example of first class functions def add(num1, num2): return num1 + num2 def sub(num1, num2): return num1 - num2 def mul(num1, num2): return num1 * num2 def div(num1, num2): return num1 / num2 def calculate(op, num1, num2): return op(num1, num2) print(f"add op result: {calculate(add, 40, 10)}") print(f"sub op result: {calculate(sub, 40, 10)}") print(f"mul op result: {calculate(mul, 40, 10)}") print(f"div op result: {calculate(div, 40, 10)}") When we print out the result of calling calculate with our various calculator functions, we get the answers you’d expect. add op result: 50 sub op result: 30 mul op result: 400 div op result: 4.0 But how? Let’s try doing the same thing in C. // 4 function calculator // An example using function pointers #include <stdio.h> int add(int num1, int num2){ return num1 + num2; } int sub(int num1, int num2){ return num1 - num2; } int mul(int num1, int num2){ return num1 * num2; } int div(int num1, int num2){ return num1 / num2; } int calculate(int (*op) (int, int), int num1, int num2){ return op(num1, num2); } int main() { printf("add op result: %d\n",calculate(&add, 40, 10)); printf("sub op result: %d\n",calculate(&sub, 40, 10)); printf("mul op result: %d\n",calculate(&mul, 40, 10)); printf("div op result: %d\n",calculate(&div, 40, 10)); return 0; } Do you see the difference? Instead of just passing op as the first argument to calculate like we did in Python, we have to pass a function pointer. We define a function pointer with an admittedly atrocious syntax int (*op) (int, int). A function pointer works just like a regular pointer. It’s still just an address in memory. But this time it points to a function rather than a value like 10. The name of the variable associated with our function pointer is op. Everything to the right of (*op) indicate the types of the arguments, and the values to the left, the type of the returned result. Since the argument and return types of our function pointer matches the 4 different functions, we can pass and use each one in our calculate function. If we were to print out the value of one of our op functions using printf("add op address: %p\n", &add) we would get… add op address: 0000000000401550 That looks an awful lot like what happened when we printed our square function. Print the square function: <function square at 0x0000022D579804A0> Print the func variable: <function square at 0x0000022D579804A0> And it turns out that’s because it is. Python is just doing the heavy lifting for us by dynamically finding out what the type of op is, but under the hood it’s just being passed a function pointer. There’s a couple more things you can do with pointers, but you get the gist. Pointers are just addresses in memory. The Stack and the Heap We’ve seen how we can use pointers to store memory addresses into variables, but we can do much more with memory in C. But before we can talk about that, we need to talk about the stack and the heap. This might come as a surprise, but C can manage memory for you, but only in a limited context. The memory that it can handle is memory that is on the stack. The stack and the heap are simply areas in ram. What makes the stack special is that it is a predefined size. The size varies by platform but on my Raspberry Pi, and 2019 MacBook pro it’s 8192Kb or 8Mb. But don’t take my word for it, you can find out the size yourself by running ulimit -s on the command line. I don’t know how to check on Windows 10, but Bing tells me it’s 1Mb 🤷. The stack is a place that stores local variables and values. Any time you do something like int my_var = 10; You are performing a stack operation. The reason being is that the program knows at compile time how much memory it needs to reserve. Even if you don’t assign a variable to an array in the same line, you can still reserve it on the stack. int main(){ int for_later; // uninitialized int arr_later[5]; // uninitialized /* Insert amazing code*/ // initializing our variables for_later = 100; arr_later[0] = 10; arr_later[1] = 10; arr_later[2] = 10; arr_later[3] = 10; arr_later[4] = 10; arr_later[5] = 10; return 0; } This is because the compiler is able to infer upfront how much memory it will need based on the type of the variable. An int is usually 4 bytes so it will reserve 4 bytes on the stack. An array with 5 integers will need 5 * 4 bytes or 20bytes total on the stack. When your program is done (in this case when our variables fall out of the main scope) then we know that it is safe to reclaim that memory and it is cleaned up. The stack works great in two scenarios. When you know the sizes of the containers you will need before a program runs When the total size of the stack is sufficient to store your entire programs data The second point bears repeating. If you know your program will fit into your stack space the stack works great. To illustrate this further let’s look at Windows since it has the smallest default stack size. I wrote a program and then increased the STACK_SIZE value until I found the breaking point. The worst part about it is that creating an array of ints that is 257,338 integers long would cause the program to crash intermittently. So, if you are right on the cusp you might not notice it at first. By exhausting my stack, I created a stack overflow. One final point. The size of the array that causes the stack overflow depends on its type. I can easily store 300,000 values inside of buff if the type is char instead of int. That’s because a char is only 1 byte as opposed to int’s 4. If you know you can get away with a smaller type this is a big advantage. If you know ahead of time that stack size will be a problem, you can always modify the default stack size, but we won’t go into that here. But what if we don’t know how large of a container we will need before the program is running, or we are concerned about the stack size? That my friends is where the heap comes in, and where most of the memory management happens. Malloc stands for Memory Allocation Imagine a program that asks a user for an integer. It will store that integer in an array, and then print out the values to the screen once the user is done typing them. We don’t know how many numbers beforehand the user wants to store, so we give it our best guess, 3. Well, what happens if the user decides they want to store 4 numbers instead of 3? I’ll tell you what happens. Bad things. The only way around this is to use the heap. Below is a program that handles allocating additional memory if the users wants to store more numbers than we initially defined. #include <stdio.h> #include <stdlib.h> int main() { /* 1. The Malloc Portion of our code */ /* Our initial array will be of size 3 */ int array_size = 3; /* malloc returns an address in memory where the data is on heap */ int* array = (int*) malloc(array_size * sizeof(int)); int i = 0; char input[10]; /* ask for a number, store number in array, reprompt for a number */ /* keep prompting for a number until the user stops giving them */ do { printf("Enter a number: "); fgets(input, 10, stdin); array[i++] = atoi(input); /* 2. The realloc portion of our code */ /* Grow the array if user asks for number > size of array */ if (i >= array_size) { array_size += 3; /* request memory at the new size */ array = (int*) realloc(array, array_size * sizeof(int)); } printf("Do you want to enter another number? (y/n): "); fgets(input, 10, stdin); } while (input[0] == 'y'); printf("You entered the following numbers:\n"); for (int j = 0; j < i; j++) { printf("%d\n", array[j]); } /* 3. The free portion of our code */ /* give the memory back */ free(array); return 0; } In our above example, the heap helps us combat the unknown quantity of memory we will need. This is the heaps advantage; it can store about as much memory as the computer is willing to allocate. Since most computers have at least 8gb of ram that means that the number of values we can store is on the order of gigabytes. The disadvantage is that storing memory on the heap requires more work compared to storing it on the stack. Because of this malloc tends to incur a small penalty performance. malloc requests memory on the heap. In our example program we ask for heap memory on the line int* array = (int*) malloc(array_size * sizeof(int)); This might seem intimidating, but at its essence it’s exactly like our int pointer_my_var = &my_var; example. At the end of the day what is stored in the array variable is a memory address, but unlike the example with my_var instead of being a memory address that stores a single value 10, the memory address is the start address of a block of data that will store our numbers. In our example malloc simply gets passed the number 12, because that’s the result of 3 (size of array) * 4 (size of int in bytes). (int*) is a cast and needs to be done because the pointer that malloc returns is technically a void pointer, but don’t worry about that. Now you know what malloc does. It requests a chunk of memory on the heap. This is a good stopping point if you are feeling overwhelmed. If you don’t fully understand that is okay! It’s hard to wrap your head around if you’ve never had to think about this stuff. I happily programmed in garbage collected languages for 7 years before I decided to give it a go myself. Once you are feeling ready, we can move onto our next section. We will continue our example, taking a look at Realloc. If malloc stands for Memory Allocation, realloc is for memory Re-Allocation. Let’s continue with our example. do { printf("Enter a number: "); fgets(input, 10, stdin); array[i++] = atoi(input); /* Grow the array if user asks for number > size of array */ if (i >= array_size) { array_size += 1; /* request memory at the new size */ array = (int*) realloc(array, array_size * sizeof(int)); } Here we are prompting for a number, converting the string of characters received to a number with array[i++] = atoi(input), then storing it in our array at a specific index. Our do while loop will continue to prompt the user until the user stops typing ‘y’ into the window. Ideally, we’d like to handle this more robustly but for our example it’s fine. Every time we loop through the code, we increment our i variable using i++ and store the resulting number our user supplies in the new index in the array. We then check the current index i and see if it’s >= our array_size variable. If it is we know that if the user supplies ‘y’ again, we will try to insert a number at an index in our array that doesn’t exist. This is called an array out of bounds error or buffer overflow. You might have heard of it. This would bring us into the zone of Undefined Behavior or UB. Alls that means is that the C language standard does not specify what should happen when this happens. For important code this means a potentially attack vector for a bad actor to exploit. This isn’t a mission critical piece of code, but we would like to avoid undefined behavior anyway. So, we should increase the size of our array by 1 every time we reach our array_size. This is exactly what happens on the array = (int*) realloc(array, array_size * sizeof(int)) line. Realloc does a lot of things. It accepts the starting pointer of the block of memory we want to resize, and a new size to extend it by. It checks if there is enough space in the memory block to accommodate the new size. If there is, it resizes it and returns a pointer to the new block of memory. If there isn’t it allocates a new block of memory, copies the contents of the old block to the new larger block, and frees the old block. We’ll talk more about free in a moment. What you get from all of this is a shiny new memory block that can is larger than the previous one. And in our case, we protect ourselves from having an array smaller than the quantity of numbers our user would like to store. Working with memory is freeing Free does exactly what you’d expect it to do. It tells the program that a memory block that was previously in use can be used for something else. Whenever you allocate memory, you should always make sure to free the memory when you are done using it. Unfortunately, like malloc and realloc, free can be another source of bugs, so you must treat it carefully. Some people avoid free all together by never calling it. You should generally avoid this. But it’s important to be aware of what could happen if you don’t handle free properly. Double Free: You free a block of memory that has already been freed. This is Undefined Behavior Memory Leak: Forgetting to free a block of memory. Dangerous if you continue to allocate more memory as the program continues to run. Can lead to out of memory errors if your program runs long enough. Dangling Pointer: Using a pointer which refers to a block of memory that has already been freed Invalid Pointer: Giving free a pointer that wasn’t created by malloc(), calloc(), or realloc() Thankfully we don’t do any of this in our program. Once we’ve printed out the numbers back to our user, we can free the array as we no longer need it. Closing thoughts There is one other type of memory allocation we didn’t end up using in our program, but you might see. Calloc is like Malloc but initializes all the values in the memory to 0. This requires an extra step, and is generally slower than malloc because of it, but useful when you need it. There is also the issue of what to do when memory allocation fails. We didn’t handle that in our program, but we could have. If a memory allocation returns null then it failed. Why might that happen? As you can see, C gives us some insight into what our garbage collected programs are doing when we run them. All of these processes still have to happen, but in language like Python, we let Python handle it. The advantage of using a non GC language is that we get control over the memory. We can pass a pointer where it is more performant to do so. We can mark memory to be freed in the non-performance critical part of our programs and keep our programs memory management deterministic. These are all great benefits. But it can be tricky to keep track of, especially if you’re still a beginner, but if you keep practicing, you’ll reap the benefits, and gain a little understanding of computers along the way. Call To Action 📣 If you made it this far thanks for reading! If you are new welcome! I’ve recently started a Twitter and would love for you to check it out. If you liked the article, consider liking and subscribing. And if you haven’t why not check out another article of mine! Thank you for your valuable time. Source Quote Link to comment Share on other sites More sharing options...
Recommended Posts
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.