The perils of Memory Management
Among the arguments used to dismiss C, those around manual memory management are surely very high in the top ten list.
It usually goes like this:
- it's error prone
- it leads to security bugs
- and, in the end, contributes to Global Warming!
Now, while some of them are a little bit exagerated, most of them are not wrong. Using dynamic memory in C is something that must be handled properly.
The main reason is that memory related bugs are difficult to track and fix. The root cause of a bug can be lines and files away from the line where the error occured.
Even worse, some bugs can happen under certain circumstances and not others; in a machine but not in a different one ... a real nightmare!
We need to find some countermeasure against the most common errors:
- not releasing memory that is no longer used (memory leaks)
- referencing uninitialized memory
- referencing memory that has been already released
- writing past the end of allocated memory (buffer overrun).
Let's see what we can do!
Garbage collection
Most modern programming language (Java, Python, Go) use a Garbage Collector: a system that tracks unreferenced memory and make them available for re-allocation. Note the Garbage Collection was used in Lisp and Basic since the '70s of the past century, it's not a modern feature per se.
Garbage Collectors (aka GC) are extremely useful but, if one is not careful, can lead to unexpected behaviour when the GC stops the world to identify the unused memory. It's not by chance that Rust, which aims to be a system programming language, does not use GC at all.
For C, the best option is probably the Boehm-Demers-Weiser conservative Garbage Collector, very mature and available for many platform.
I don't feel comfortable relinquishing my environmental cleaning duties to someone else, but there are cases where using a GC is the most appropriate thing to do.
Valgrind
The ultimate weapon for tracking memory bugs is Valgrind: a suite of tools for debugging and profiling your applications. Using on of these tools, memcheck
, you will easily identify memory leaks, buffer overrun, and much more. It can really save your day!
However it has some drawbacks:
- you have to compile it with all debugging information in (
-g
in most compilers) and with no optimization - it can be painfully slow (20 or 30 times slower) because the code is not executed by the CPU but is re-interpreted by Valgrind.
- it only works on Linux and MacOS (and some other Unix platform)
- it's a complex tool that needs some time to be mastered
That said, if you have a complex application, possibly inherited by some long time gone colleague, Valgrind is the best option to help you analyze the application and identify memory issues. Give it a try if you didn't already.
Tracking memory
It often happens that you need far less than sofisticated tools like a GC or Valgrind; you may just need something that helps you tracking your memory usage.
At least, it often happens to me, that's why I wrote a set of function to replace the standard malloc(), free(), etc ... that write out on stderr what they are doing.
This way I can detect:
- invalid pointers
- doubly freed blocks
- buffer overrun
- memory leaks
and I can leave Valgrind for later being confident that I won't find that many memory issues.
You can find memchk
, a minimal implementation, on Github to see how I did it.
Usage
To use memchk
you just:
- include the memchk.h header
- compile with the symbol MEMCHK defined
- add memchk.c to your sources
No need to change your code at all (besides including the header)!
When you're confident that everything works fine, you can recompile with MEMCHK not defined and the standard functions will be used instead.
There is only one new function memchk() which takes a pointer and exit the program with an error if it's not a valid pointer (NULL pointer are considered valid).
A really basic example mtest1.c
:
1: #include <stdio.h>
2: #include <stdlib.h>
3: #include "memchk.h"
4:
5: int main(int argc, char *argv[])
6: {
7: char *s;
8:
9: s = malloc(10);
10: // s[10] = '\0'; // Buffer overflow!
11: free(s);
12: }
When compiled and executed, will produce a log with all the relevant info about memory usage:
> gcc -O2 -c memchk.c
> gcc -O2 -c -DMEMCHK mtest1.c
> gcc -o m1 mtest1.o memchk.o
> ./m1.c
malloc(10) mtest1.c:9
-> 0x5634ea39a2b0 +10 -0 mtest1.c:9
free(0x5634ea39a2b0) mtest1.c:11
MEMCHK: 0x5634ea39a2b0 mtest1.c:11
-- OK mtest1.c:11
-> (nil) +0 -10 mtest1.c:11
If line 10 would be uncommented, the result would be quite different:
malloc(10) mtest1.c:9
-> 0x55ed8b0222b0 +10 -0 mtest1.c:9
free(0x55ed8b0222b0) mtest1.c:11
MEMCHK: 0x55ed8b0222b0 mtest1.c:11
-- KO: BUFFER OVERRUN mtest1.c:11
Aborted
As you can see the fact that the block boundaries have been crossed is detected.
I choose to abort as soon as something wrong is detected, there's no point in letting the error propagating through the program.
Detecting memory leaks is quite easy. You can write a simple program (or a script) that selects the lines starting with ->
and sums the +x
and -y
. If you don't get 0 at the end, you have forgot to release some memory. At the same time you can also keep track of how much memory you are using.
Under the hood
The trick is very simple. The new functions manage a block that is bigger than what requested, two sentinels will be put at the beginning and at the end of the allocated block so that one can check them for validity and buffer overrun:
┏━━━━━━━━━━━━━━┓ ◄── Actually allocated memory
┃ 0xCA5ABA5E ┃ ◄── Sentinel to identify valid blocks
┣━━━━━━━━━━━━━━┫
┃ ┃ ◄── Block size
┣━━━━━━━━━━━━━━┫ ◄── Returned pointer
┃ ┃
~ ~
┃ ┃
┣━━━━━━━━━━━━━━┫
┃ 0x10CCADD1 ┃ ◄── Sentinel to identify overflow
┗━━━━━━━━━━━━━━┛
End notes
This approach is not perfect, it just covers the most common cases. For example, one could cause a buffer overrun without deleting the end sentinel but buffer overrun mostly happen when copying sequentially from a block of memory to another so, deleting the sentinel would be the most frequent case.
Also, it does not pinpoint exactly the line where the bug occurs (line 10) but, at least, won't leave the bug unnoticed. Note that if you recompile everything without MEMCHK defined, you will have a nice program that seems to behave correctly. At least until something goes terribly wrong in production!
Conclusion
Memory management is a serious matter. Wether you decide to use a Garbage collector or to do all on your own, you have to put the maximum effort in ensuring it is done properly.
Luckily we have options and tools at our disposal to do our job. Say NO to memory issues!
Post Scriptum
I've been reminded that the C11 standard (in its Annex K) defined a set of functions (ending with _s
) that are safer to use than their traditional counterpart. The same Annex is in the C17 and the draft C2x standard documents. For example strcpy_s()
will never overflow memory boundaries.
While this is true, it is also true that compilers support for these functions is optional. Neither gcc (v9.30) nor clang (v10.0) implement them (you can tell by them not having __STDC_LIB_EXT1__
defined) and it doesn't seem they'll do it in the near future.
Last time I checked, even Microsoft cl
(v19.29) did not implement Annex K completely, even if the reason the Annex is there is because those "_s
" functions where introduced by Microsoft in its compiler.
So, for how much I like to follow the standard, it doesn't seems we can count on Annex K to help us ensuring our memory management is sound and clean :(
Top comments (1)
I am a fan of enforcing clear ownership in my code, battle hardened data structures, and recyclable memory pools for code with a finite runtime.
I actually adore c memory flexibility.