I've always loved the C programming language. Because it forces me to understand and tighten my knowledge of what is happening underneath the abstractions I normally work with, particularly in the realm of game development.
I want to start using C to build more smaller games, simulations and other projects where having direct control over the program is all a part of the fun.
One of the easiest things to get confused about when writing C programs is how pointers and memory work, and how to avoid undefined behaviour and memory leaks.
I wrote these notes as a way to cement my understangin of the subject. and hopefully they may also be useful to others interested in learning C.
A pointer is a variable that contains a memory address. The address is where the actual data is stored.
A string literal such as "Hello world" can be referenced using a pointer declaration. This creates a pointer to the first character of the string literal.
String literals have static storage duration, meaning they exist for the lifetime of the program.
// Create a pointer called greeting, and put the address of the
// first character of the string literal in it.
char *greeting = “Hello world”;
The value stored in greeting is the address of the first character of the string literal, which is 'H'.
Creating the same string using an array declaration has a different meaning. This declaration creates an array and copies the characters of the string literal into the array.
// Create an array called greeting, and put the characters in the array.
char greeting[] = "Hello world";
The array contains the characters themselves, including the terminating
\0.
In both versions individual characters can be referenced by their index:
printf(“%c\n”, greeting[0]); // prints “H”
printf(“%c\n”, greeting[1]); // prints “e”
With a pointer, greeting[index] accesses the character at an offset
from the address stored in the pointer.
With an array, greeting[index] accesses the character at that index
in the array.
Another more visual way to declare the array is with the following:
char greeting[3];
greeting[0] = ‘H’;
greeting[1] = ‘i’;
greeting[2] = ‘\0’
printf(“%s”, greeting); // -> “Hi”
Note the last element must be a null terminator. This tells the computer that this is where the string ends.
If the null terminator is missing, printf("%s", greeting) does not
know where the string ends. It will continue reading memory beyond the
array looking for a \0.
char greeting[2];
greeting[0] = ‘H’;
greeting[1] = ‘i’;
printf("%s", greeting); // undefined behaviour
printf reads beyond the end of the array. The result is not predictable: it might print Hi followed by garbage, or behave in some other way.
In this example the string is created as an array. The resulting variable is local to the function and when the function returns, the lifetime of the array ends.
The returned pointer will then be pointing to an invalid memory address resulting in undefined behaviour. This is known as a dangling pointer.
char *get_greeting()
{
// lifetime of the value greeting starts here
char greeting[] = “Hello world”;
return greeting;
// lifetime of the value of greeting ends here
}
int main()
{
char *greeting = get_greeting();
printf(“%s”, greeting); // undefined behaviour
}
If get greeting is written as below using the pointer declaration.
char *get_greeting()
{
// lifetime of the value greeting is static and lasts the duration of the //program
char *greeting = “Hello world”;
return greeting;
}
int main()
{
char *greeting = get_greeting();
printf(“%s”, greeting);
}
Now the pointer returned by get_greeting points to the memory address of the string literal “Hello world” which lives for the duration of the program. The pointer returned to main stays valid.
We can write the same again with manually allocated memory.
char *get_greeting()
{
// lifetime of the value greeting lasts until it is manually freed
char *greeting = malloc(sizeof(char) * 12);
strcpy(greeting, "Hello world");
return greeting;
}
int main()
{
char *greeting = get_greeting();
printf(“%s”, greeting);
free(greeting); // frees the allocated memory
}
malloc() allocates 12 bytes of memory and returns the address of the first byte.
strcpy(greeting, "Hello world") copies the characters of the string, including the null terminator \0, into the allocated memory
The pointer returned by get_greeting() therefore contains the address of the first byte of the allocated memory, which now contains "Hello world".
The allocated memory remains valid until free() is called on the pointer. After free(), the allocated object's lifetime has ended and the pointer should no longer be used to access that memory.
char *greeting = get_greeting();
free(greeting);
printf("%s\n", greeting); // undefined behaviour as greeting is a dangling pointer
The pointer variable greeting still exists after free(), but the memory it points to is no longer a valid object. The pointer has therefore become a dangling pointer.
If the pointer is not freed and the program loses the ability to free it then a memory leak is created.
char *greeting = get_greeting();
greeting = NULL;
Assigning NULL to greeting overwrites the only pointer to the allocated memory. The memory is still allocated, but we no longer have
its address and therefore cannot call free() on it.
Storage duration describes how long an object exists in memory.
malloc(); exists until it is explicitly released with free().Storage duration and pointer lifetimes can be different. This can result in a memory leak, where the allocated object outlives the pointer to it, or a dangling pointer, where the pointer outlives the object it points to.