Detecting memory management bugs with GCC 11

The first half of this article described dynamic memory allocation in C and C++, along with some of the new GNU Compiler Collection (GCC) 11 features that help you detect errors in dynamic allocation. This second half completes the tour of GCC 11 features in this area and explains where the detection mechanism might report false positives or false negatives.

Throughout this article, I include links to the code examples on Compiler Explorer for those who would like to experiment. You will find the links above the source code of each example.

Note: Read the first half of this article: Detecting memory management bugs with GCC 11, Part 1: Understanding dynamic allocation.

Mismatches between new and delete operators

A peculiarity of C++ is that calls to a specific form and overload of operator new() must be paired with the corresponding form and overload of operator delete(). C++ provides many forms of these operators out of the box, and GCC knows about all those. But besides these, C++ programs can also define their own overloads of these operators. Those that do must make sure to define them in matching pairs. Otherwise, attempts to deallocate objects allocated by the wrong form can easily lead to the same insidious bugs as other mismatches. In addition, a class that declares the scalar forms of the operators should also declare a pair of the corresponding array forms. (See also R.15: Always overload matched allocation/deallocation pairs in the C++ Core Guidelines.)

For example, suppose we define a class that manages its own memory. We define member forms of operator new() and operator delete(), but neglect to define the array forms of the operators. Next, we allocate an array of objects of the class that we then try to deallocate, as shown in the following snippet. GCC 11 detects this mistake and issues a -Wmismatched-new-delete warning pointing it out. Because operator new() and operator delete() are recognized as special, this happens even without attribute malloc. The array_new_delete test case shows an example:

#include <stddef.h>

struct A
{
  void* operator new (size_t);
  void operator delete (void*);
};

void f (A *p)
{
  delete p;
}

void test_array_new_delete (void)
{
  A *p = new A[2];
  f (p + 1);
}

The compiler warning is:

In function 'void f(A*)',
    inlined from 'void test_array_new_delete()':
warning: 'static void A::operator delete(void*)' called on pointer returned from a mismatched allocation function [-Wmismatched-new-delete]
11 | delete p;
   | ^
In function 'void test_array_new_delete()':
note: returned from 'void* operator new [](long unsigned int)'
16 | A *p = new A[2];
   |        ^

Notice how the example calls f() with a pointer that doesn't point to the beginning of the allocated array, and the warning is still able to detect that it's not a valid argument to the deallocation function. Even though the warning is in effect even without optimization, the example must be compiled with the -O2 option in order for GCC to find the bug. That's because the invocations of the new and delete expressions are in different functions that GCC must inline in order to detect the mismatch.

Deallocating an unallocated object

Another dynamic memory management bug is attempting to deallocate an object that wasn't dynamically allocated. For example, it's a bug to pass the address of a named object such as a local variable to a deallocation function. Usually, the deallocation call will crash, but it doesn't have to do that. What happens tends to depend on the contents of the memory that the object points to. GCC has long implemented a warning to detect these kinds of bugs: -Wfree-nonheap-object. But until GCC 11, the option detected only the most obvious bugs involving the free() function—basically just passing the address of a named variable to it. GCC 11 has been enhanced to check every call to every known C or C++ deallocation function. In addition to free(), these functions include realloc() and, in C++, all non-placement forms of operator delete(). In addition, calls to user-defined deallocation functions marked up with attribute malloc are checked. As an example, see free_declared:

#include <stdlib.h>

void use_it (void*);

void test_free_declared (void)
{
  char buf[32], *p = buf;
  use_it (p);
  free (p);
}

The compiler warning is:

In function 'test_free_declared':
warning: 'free' called on unallocated object 'buf' [-Wfree-nonheap-object]
9 | free (p);
  | ^~~~~~~~
note: declared here
7 | char buf[32], *p = buf;
  |      ^~~

Attribute alloc_size

Besides the two forms of attribute malloc, one other attribute helps GCC find memory management bugs. The attribute alloc_size tells GCC which of an allocation function's arguments specify the size of the allocated object. For instance, the malloc() and calloc() functions are implicitly declared like so:

__attribute__ ((malloc, malloc (free, 1), alloc_size (1)))
void* malloc (size_t);

__attribute__ ((malloc, malloc (free, 1), alloc_size (1, 2)))
void* calloc (size_t, size_t);

Making use of attribute alloc_size helps GCC find out-of-bounds accesses to allocated memory. The my_alloc_free example shows how to use the attribute with a user-defined allocator:

void my_free (void*);

__attribute__ ((malloc, malloc (my_free, 1), alloc_size (1)))
void* my_alloc (int);

void* f (void)
{
  int *p = (int*)my_alloc (8);
  memset (p, 0, 8 * sizeof *p);
  return p;
}

The compiler warning is:

In function 'test_memset_overflow':
warning: 'memset forming offset [8, 31] is out of the bounds [0, 8] [-Warray-bounds]
9 | memset (p, 0, 8 * sizeof *p);
  | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

Limitations

Being new, GCC 11's detection of dynamic memory management bugs isn't perfect. The warnings are susceptible to both false positives and false negatives.

False positives

False positives are typically caused by GCC failing to determine that certain code paths are unreachable. This tends to affect especially -Wfree-nonheap-object in code that uses either a declared array or a dynamically allocated buffer depending on some condition, and then deallocates the buffer based on some other but equivalent condition. When GCC cannot prove the two conditions are equivalent, it might issue the warning. GCC bug 54202 shows how this might happen. It's worth noting that the bug was submitted in 2012 against GCC 4.7. So the warning is quite old, but the original implementation detected only the most basic kinds of bugs, so false positives were rare. But because GCC 11 has enhanced the implementation of the warning to check every call to every known deallocation function, these sorts of false positives are going to come up more frequently, in proportion to the number of bugs found. The test case from bug 54202 can be seen on Compiler Explorer:

typedef struct Data
{
  int refcount;
} Data;

extern const Data shared_null;

Data *allocate()
{
  return (Data *)(&shared_null);
}

void dispose (Data *d)
{
  if (d->refcount == 0)
    free (d);
}

void f (void)
{
  Data *d = allocate();
  dispose (d);
}

The compiler warning is:

In function 'dispose',
    inlined from 'f':
warning: attempt to free a non-heap object 'shared_null' [-Wfree-nonheap-object]
18 | free(d)
   | ^~~~~~~

We do hope these cases won't be too common, but plan to reduce them even further in future updates. Until then, when they happen, we recommend using #pragma GCC diagnostic to disable the warning.

False negatives

Similarly, but to a greater extent, buggy code that conditionally tries to deallocate an unallocated object may not be diagnosed at all. These false negatives are quite common and unavoidable in general, due to the limitations of the analysis in GCC. One major reason is that the accuracy and depth of the analysis depend on optimization in general and on inlining in particular. Besides being enabled only with optimization, inlining is subject to constraints designed to strike an optimal balance between speed and space efficiency. Whether a call to a particular function is inlined into its caller depends on its benefits to the caller. Internally, the profitability is determined by the size of the function in GCC pseudo-instructions. This constraint is controlled by the -finline-limit= option. Setting the option to a suitably high value has the effect of inlining most functions defined in a translation unit and exposing their bodies to the analysis. (Changing the limit is not recommended for code that is to be released.) In addition, Link-Time Optimization (LTO), enabled by the -flto option, applies the same analysis to functions inlined across translation unit boundaries.

That being said, we are aware of a class of false negatives that aren't subject to inlining heuristics and where it's possible to do better. We are hoping to tackle some of those in future releases. An example of a limitation that's solvable, albeit not easily, is in the code that follows. Because the call to f() might overwrite the value that g() stores in *p, issuing a warning would be a false positive in those cases. If f() doesn't modify the object passed to it, declaring the function to take a const void* argument instead might seem like a solution. But because it isn't an error to cast constness away, GCC must conservatively assume that the function might, in fact, do so. This assumption is necessary in order to emit correct code, but warnings can reasonably make stronger assumptions, albeit at the cost of some false positives for strictly correct (though clearly questionable) programs. Implementing these stricter assumptions is among the enhancements considered for future releases.

void f (void*);

void g (int n)
{
  char a[8];
  char *p = 8 < n ? malloc (n) : a;
  *p = n;
  f (p + 1);    // might change *p
  if (*p < 8)
    free (p);   // missing warning
}

Conclusion to Part 2

GCC 11 can find many memory management bugs out of the box, without making any changes to program source code. But to get the most out of these features in code that defines its own memory management routines, you can benefit by annotating these functions with the attributes malloc and alloc_size. GCC checks all calls to memory allocation and deallocation functions even without optimization, but detection in the absence of optimization is limited to the scope of function bodies. With optimization, the analysis also includes functions inlined into their callers. Raising the inlining limit improves the analysis, as does LTO. The GCC static analyzer performs the same checking but considers all functions in the same translation unit, regardless of inlining.

Last updated: February 11, 2024