Finding And Preventing Bugs In c++
Finding And Preventing Bugs In C++
Following the recent articles of Compiler Options Hardening Guide for C and C++ and Why I think C++ is still a desirable coding platform compared to Rust, and ThePrimeagen’s reaction to the first one, I guess I can’t help chiming in here.
I’ve been writing C++ in some form or another since 2008-ish. Always a good experience looking at your old code. So many copies of std::string everywhere. But also, it was all C++03 and I wasn’t using all the things the Boost library had to offer at that time: Boost smart pointers, Boost networking, Boost optional, the list goes on. A lot of the good things we got in C++11 were already present in some form in Boost.
Even back then, people were working on improving common sources of bugs in C++. Smart pointers, for example, solve a lot of issues around leaked memory. Allocated somewhere, pointers passed, but never freed.
So what does C++ have to offer to reduce the number of bugs? Or to help find them?
Let’s start with a list of bugs I regularly encounter in C++, without any specific order:
- Integer overflows/underflows
- Off-by-one errors
- Iterator Invalidation
- Dereferencing a nullptr
- Uncaught exceptions
- Logic errors
- Data Races
- Mutex deadlocks
Some of these can be found in any language, but the first three are related more to the language. Let’s take a look at how I setup projects to reduce the occurences of these.
Warnings and turning them into errors
One of the biggest changes in the past decade or so is that C++ compilers have included a lot of warnings. However, as usual in C++, these are not turned on by default. Jason Turner, someone you should follow on Youtube if you haven’t yet, has compiled a list of warnings you should enable for each of the three major compilers.
However, since some warnings only trigger without optimizations, some only with and some are only implemented on a specific compiler, the advice is as follows:
- Enable as many warning flags as you possibly can and use
-Werror
(MSVC:/WX
) to turn them into errors - Compile your code on all three major compilers.
- For each compiler, compile at least without optimizations and with optimizations.
For an example of what warnings can do for you, there is GCC’s and Clang’s -Wnon-virtual-dtor
warns on the following situation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <vector>
#include <memory>
#include <iostream>
struct noisy {
noisy() {
std::cout << "noisy()\n";
}
~noisy() {
std::cout << "~noisy()\n";
}
};
struct parent {
// missing virtual destructor
virtual void fn() = 0;
};
struct child : public parent {
child() {
i = std::make_unique<noisy>();
}
// Due to missing virtual destructor in parent, the destructor of child will never get called
void fn() final {
return;
}
std::unique_ptr<noisy> i;
};
int main() {
std::vector<std::unique_ptr<parent>> p;
p.push_back(std::make_unique<child>()); // child destructor is never called, the allocation of noisy is never freed, despite using unique_ptr
}
Compiler Flags Debug
A big part of the safety net I have comes from compiler flags. My process involves setting specific compiler flags while working a new feature. Flags that slow down the build and change ABI, but catch a lot of silly mistakes. This is similar to what Rust does by default, except like pretty much every good thing in C++, you have to opt-in to it yourself. No wonder why the learning curve is so big.
libstdc++: _GLIBCXX_DEBUG & _GLIBCXX_DEBUG_PEDANTIC
_GLIBCXX_DEBUG
enables the GCC Debug mode and _GLIBCXX_DEBUG_PEDANTIC
enables more checks when this mode is enabled.
Defining these macros (preferably as a g++ command line option, as they have to be defined before any include of the STL) has a big effect on most STL classes (from the docs):
- Safe iterators: Iterators keep track of the container whose elements they reference, so errors such as incrementing a past-the-end iterator or dereferencing an iterator that points to a container that has been destructed are diagnosed immediately.
- Algorithm preconditions: Algorithms attempt to validate their input parameters to detect errors as early as possible. For instance, the set_intersection algorithm requires that its iterator parameters first1 and last1 form a valid iterator range, and that the sequence [first1, last1) is sorted according to the same predicate that was passed to set_intersection; the libstdc++ debug mode will detect an error if the sequence is not sorted or was sorted by a different predicate.
e.g. constructing a std::string
from a nullptr
throws an exception when using Debug mode.
Two important pieces of information:
- Some debug mode changes only trigger when compiling with optimizations because of
std::string
explicit template instantiations. Just like with warnings, be sure to compile and run your code with and without optimizations. - There is a warning in the docs is that compiling your code in Debug mode changes ABI. That means that if you are using another library, that library also has to be compiled in Debug mode. Otherwise you will at best get compiler errors and at worst undefined behaviour.
- This is also the reason why I like Rust’s cargo: it recompiles all dependencies necessary if any specific flags changes. At least, I think, I’m not a Rust guru. However, C++ libraries are often provided as a precompiled library, making the use of Debug mode difficult in that situation.
libc++: _LIBCPP_DEBUG=1
This is the Clang counter-part to GCC’s _GLIBCXX_DEBUG
with much of the same consequences. I will refer you to their docs for more info on the nuances. One caveat here is that if you’re compiling on e.g. Ubuntu, Clang still uses the distro installed stdlib, which in the case of Ubuntu is Gcc’s libstdc. Be sure to add -stdlib=libc++
if you want to use _LIBCPP_DEBUG
.
libc++: _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG
Since July 12th, LLVM supports setting hardening mode. This means that current clang git supports setting this. While it may not be feasible to use a git compiler version for production code, on Ubuntu at least, it is easy to install the compiler and use it for local use.
Setting hardening mode does the following things (copied directly from the RFC):
- “valid-input-range” — checks that a range given to a standard library function as input is valid, where “valid” means that the end iterator is reachable from the begin iterator, and both iterators refer to the same container. The range may be expressed as an iterator pair, an iterator and a sentinel, an iterator and a count, or an std::range. This check prevents out-of-bounds accesses and as such is enabled in the hardened mode (and consequently the debug-lite and debug modes as they are supersets);
- “valid-element-access” — checks that any attempt to access a container element, whether through the container object or through an iterator, is valid and does not go out of bounds or otherwise access a non-existent element. Types like optional and function are considered one-element containers for the purposes of this check. As this check prevents out-of-bounds accesses, it is also enabled in the hardened mode and above.
- “non-null-argument” — checks that a given pointer argument is not null. On most platforms dereferencing a null pointer at runtime deterministically halts the program (assuming the call, being undefined behavior, is not elided by the compiler), so by default this category is not enabled in the hardened mode; it is enabled in the debug-lite and debug modes. We may consider exploring ways to detect platforms where that’s not the case and making this category a part of the hardened mode on those platforms.
- “non-overlapping-ranges” — for functions that take several non-overlapping ranges as arguments, checks that the given ranges indeed do not overlap. Enabled in the debug-lite mode and above. This check is not enabled in the hardened mode because failing it may lead to the algorithm producing incorrect results but not to an out-of-bounds access or other kinds of direct security vulnerabilities.
- “compatible-allocator” — checks any operations that exchange nodes between containers to make sure the containers have compatible allocators. Enabled in the debug-lite mode and above. This check is not enabled in the hardened mode because it does not lead to a direct security vulnerability, even though it can lead to correctness issues.
- “valid-comparator” — for algorithms that take a comparator, checks that the given comparator satisfies the requirements of the function (e.g. provides strict weak ordering). This can prevent correctness issues where the algorithm would produce incorrect results; however, this check has a significant runtime penalty, thus it is only enabled in the debug mode.
- “internal” — internal libc++ checks that aim to catch bugs in the libc++ implementation. These are only enabled in the debug mode.
For more information on what this mode does and what options there are, please see the RFC.
Honerable mention: _ITERATOR_DEBUG_LEVEL
So MSVC also provides checked iterators, and enables this by default when compiling with the Debug setting, and would have recommended using this in production. However, it seems that it cannot be turned on when compiling in Release mode
Compiler Flags Release mode
Obviously, the previously mentioned flags have a noticable impact on ABI or performance. As such, using those when actually deploying code in production would be a bad idea. However, there are still flags I use in that situation:
GCC/Clang: fwrapv / ftrapv
One of the philosophies of C/C++ has been to rely on undefined behaviour to optimize your program. Integer overflows/underflows are one such area where the compiler not only can but actively does optimize your code to be different than what you expect.
I’ll copy this answer from StackOverflow as it does a really good job of explaining the fwrapv
flag:
Think of this function:
1 2 3 int f(int i) { return i+1 > i; }Mathematically speaking, i+1 should always be greater than i for any integer i. However, for a 32-bit int, there is one value of i that makes that statement false, which is 2147483647 (i.e. 0x7FFFFFFF, i.e. INT_MAX). Adding one to that number will cause an overflow and the new value, according to the 2’s compliment representation, will wrap-around and become -2147483648. Hence, i+1>i becomes -2147483648>2147483647 which is false.
When you compile without -fwrapv, the compiler will assume that the overflow is ‘non-wrapping’ and it will optimize that function to always return 1 (ignoring the overflow case).
When you compile with -fwrapv, the function will not be optimized, and it will have the logic of adding 1 and comparing the two values, because now the overflow is ‘wrapping’ (i.e. the overflown number will wrap according to the 2’s compliment representation).
The difference can be easily seen in the generated assembly - in the right pane, without -fwrapv, function always returns 1 (true).
ftrapv
is similar but different: the compiler will then generate code to monitor and catch underflows/overflows and causes a SIGABRT
if it happens. Depending on how safety-critical your product is, this might be preferable to a wrong output. E.g. if you’re monitoring a water valve for a nuclear reactor, you’d rather have to restart the program than to continue in an unknown state.
So this code disables an ‘optimization’. And if you’re in the business of high frequency trading, then by all means, don’t use this flag. For the remaining 99% of us, please do enable this flag.
libstdc++: _GLIBCXX_ASSERTIONS
_GLIBCXX_ASSERTIONS
causes the many __glibcxx_assert
function calls to all become run-time evaluated. Without this flag, all the asserts found all over libstdc++ check if they’re in a constexpr context and if not, do not execute the assert. e.g. std::to_chars
contains a lot of intermediary checks to validate conversion. Similarly, std::futex
and std::fs::path
contain these checks.
Although I might be missing something, it seems that _GLIBCXX_ASSERTIONS
does not have wide usage in libstdc++. Even so, in the few places it is used, it only introduces some if checks and it is unlikely that you will be able to measure the run-time overhead in most situations.
Optionally, define _GLIBCXX_VERBOSE_ASSERT
to make the output of the asserts more verbose.
Clang: _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST
This is the same flag as described above, just with a different value. The RFC referred to above mentions using the value _LIBCPP_HARDENING_MODE_FAST
for production code, to still get some of the benefits but without unreasonable run-time overhead and no ABI changes.
MSVC: /permissive-
MSVC by default allows for non-standards compliant code to compile. e.g.
1
2
3
4
5
6
7
8
9
10
void func() {
int array[] = {1, 2, 30, 40};
for each (int i in array) // error C4496: nonstandard extension
// 'for each' used: replace with
// ranged-for statement:
// for (int i: array)
{
// ...
}
}
The C++ standard does not contain for each
, but MSVC provided this extension from before ranged-for statements were introduced in C++11.
For more info and examples, please see the MSVC docs
MSVC: /Zc:throwingNew /Zc:__cplusplus /Zc:preprocessor /volatile:iso /Zc:inline /Zc:referenceBinding /Zc:strictStrings /Zc:templateScope /await:strict
Even with /permissive-
, MSVC does not entirely conform to spec. I use the listed options to force more standards conformance. I’m going to be lazy and refer you to the docs for what they do exactly.
Modern C++ patterns
Aside from compiler flags, the newer C++ versions and other community created libraries have created patterns that decrease the chances for bugs in C++ code. I’ll go over some of these.
not_null
One of the benefits of using references is that they cannot be nullptr
. However, it isn’t always feasible to pass around references everywhere. In those cases where you still want to convey to the user of your code that the argument cannot be nullptr
, but you have to use pointers, the not_null
class helps you out.
Microsoft’s made a library called Guidelines Support Library, used to complement the C++ Core Guidelines. The not_null
class is a header-only file you can find here.
This changes the following code to:
1
2
3
4
5
6
7
void fn(my_class *ptr) {
// we probably need a comment here to explain that we expect `ptr` to never be null
// But we don't know that for sure at this point.
ptr->my_fn();
}
fn(nullptr); // nothing prevents users from calling the function with a nullptr
Into
1
2
3
4
5
void fn(not_null<my_class> ptr) {
ptr->my_fn();
}
fn(nullptr); // this simply does not compile
AlwaysNull
Similarly to not_null
, I use the AlwaysNull
class in Ichor. Ichor uses dependency injection and to help with function overloading, one of the arguments of the injection function has to contain the type that is inserted:
1
void handleDependencyRequest(AlwaysNull<ITimerFactory*>, DependencyRequestEvent const &evt);
Using AlwaysNull tells the user that the given argument is not to be used, conveying intent better.
Smart Pointers
One of the more known features of C++11 are the introduction of smart pointers: unique_ptr
and shared_ptr
. As such, I won’t go into too much detail here. Just use them judiciously and avoid calling new
, malloc
, free
, delete
and similar functions as much as possible.
Error Handling
There are multiple downsides of exceptions in C++:
- Function definitions don’t show you which exceptions could be thrown
- You are not forced to catch exceptions
- Using exceptions for returning errors introduce run-time overhead
- Usually this incurs negligible overhead for when no exceptions are thrown, but if you throw exceptions, the code has to walk the stack, unwind it and call the exception handler
- Even if you add
noexcept
to your function, if yournoexcept
function calls any non-noexcept
function, the compiler inserts a try/catch-terminate block.
Therefore, these days I use the combination of monadic optional
and expected
for errors instead. TartanLlama has implementations for both, as the monadic part of it isn’t to be introduced until C++23
This turns code like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
some_object throwing_fn() {
if(some_error) {
throw my_recoverable_error();
}
if(some_other_error) {
throw my_unrecoverable_exception();
}
// guaranteed success at this point
return some_object{};
}
try {
some_object obj = throwing_fn();
// do something with obj
} catch (const my_recoverable_error &) {
// ... some recovery ...
try {
throwing_fn(); // call fn again
} catch(...) { // catch again? Use goto? This is pretty ugly.
}
} catch (const my_unrecoverable_exception &) {
// probably print some error and tell user action failed
} catch (...) { // good practice to catch exceptions of all other types, as we don't know if the implementation changes and throws something else
}
Instead, if we use expected
combined with the compiler flag -Wswitch
(enabled be -Wall
), it would look much more clean:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <tl/expected.h>
enum class fn_errors{
RECOVERABLE,
UNRECOVERABLE
};
tl::expected<some_object, fn_errors> fn() {
if(some_error) {
return tl::unexpected(my_recoverable_error());
}
if(some_other_error) {
return tl::unexpected(my_unrecoverable_exception());
}
// guaranteed success at this point
return some_object{};
}
tl::expected<some_object, fn_errors> obj;
while(!obj) {
auto ret = fn();
if(!ret) {
// using a switch will cause the compiler to give errors (please use `-Werror`) if we're missing a case
switch ret.error() {
case fn_errors::UNRECOVERABLE:
// probably print some error and tell user action failed
goto endOfLoop;
case fn_errors::RECOVERABLE:
break; // just break the switch statement and retry
}
// recoverable error
continue;
}
//do something with obj
break;
}
endOfLoop: // breaking out of nested loops/scopes is the last good reason to use goto in C++
Purposely using different hash maps
The STL provides several containers such as unordered_map
and unordered_set
which have only a few situations in which iterators can be invalidated. Because in most situations the iterators are stable and only in edge-cases they become invalidated, to the untrained eye this suggests that inserting items while iterating over containers is allowed. Sadly, this is not a good idea.
Consider the following example:
1
2
3
4
5
6
7
void duplicate_if_found(std::unordered_map<int, std::string> &m, std::string_view to_find)
for(auto &[index, str] : m) {
if(str == to_find) {
m.insert(index+1, str); // this can cause the iterator to invalidate if the map needs to rehash
}
}
Using something like Ankerl’s unordered_dense hash map, the iterator is guaranteed to be invalidated. That way, combined with sanitizers or Debug Mode, you will be more likely to find the errors yourself before you go to production.
Aside from that, the STL implementation of the unordered containers are slow in comparison to modern hashmaps.
Testing
One topic that is big enough to warrant its own post is that of testing. I personally use Dependency Injection to make testing isolated parts of my code easier to test and then use a framework like Catch2 or googletest to create the tests. I aim for about an 80% coverage, depending on if it makes sense to test a part of the code or not.
Combining testing with all other mentioned approaches in this post lead me to catch almost all of my errors before they are deployed in production.
Modern C++ tools
Aside from compiler flags and code patterns, there are several tools that I use to catch errors
Sanitizers
Gcc and clang provide multiple sanitizers: AddressSanitizer, UndefinedBehaviourSanitizer and ThreadSanitizer (there are more, but they are usually included in one of these three).
Enabling these tells the compiler to compile instrumented code that catches errors as they happen at run-time. E.g., the new
, malloc
, free
and delete
functions are overridden. Instead of returning exactly the amount of memory requested for an allocation, space before and space after the object are added. If parts of those memory then get written, the sanitizer can check if it has changed. As soon as an error is discovered, the error and stacktrace are displayed and the program is aborted.
While many people might know about this, there are a couple of extra options I use to increase the effectiveness:
_GLIBCXX_SANITIZE_VECTOR
Allows the sanitizer to error for libstdc++ on memory access to the unused capacity of a vector
ASAN_OPTIONS
I have added the following to my .bashrc
:
1
export ASAN_OPTIONS="detect_stack_use_after_return=1:fast_unwind_on_malloc=0:detect_invalid_pointer_pairs=1"
detect_stack_use_after_return
Is off by default but enabling it tells AddressSanitizer to also look at memory accesses to stack after a return
fast_unwind_on_malloc
Is on by default and disabling it slows down the code even more, but AddressSanitizer often has difficulties with displaying a proper stacktrace. Disabling fast unwind allows the sanitizer to do more accurate bookkeeping.
detect_invalid_pointer_pairs
Is off by default, enabling it also instruments the comparison operators (<
, <=
, etc) with pointer operands.
UBSAN_OPTIONS
The UBSAN_OPTIONS
is a lot lighter, I only tell UBSAN to print stacktraces:
1
export UBSAN_OPTIONS="print_stacktrace=1"
Linux Kernel Recompile
For linux users, if you feel up to it, recompile the kernel with these options:
CONFIG_PAGE_POISONING
With this option enabled, and page_poison=1
added to the kernel command line (explained below), whenever memory is freed, the kernel changes the entire memory range to a predetermined value (e.g. 0x80). Using unitialized memory in your C++ code then predictably leads to crashes, as you’re accessing memory outside of the programs range.
CONFIG_INIT_ON_ALLOC_DEFAULT_ON
Similar to CONFIG_PAGE_POISONING
, this option sets all heap memory to 0 on initialization. This option may be turned on by default in your distro, but it interferes with CCONFIG_PAGE_POISONING
, as it sets everything to 0 on allocation. It’s not that bad, as having deterministic zeroed-out memory will also give you some benefit in debugging.
CONFIG_SLUB_DEBUG
This parameter should be on in most distros and should not need a recompile. But it is necessary for the linux boot parameters I explain below.
Other Parameters
The following parameters might help, but I have personally not tested them:
CONFIG_DEBUG_PAGEALLOC
: tracks certain types of memory corruption in pages, provided the debug_pagealloc=on
boot param is present. Note that this interferes with hibernation.
CONFIG_PAGE_OWNER
: provides a stacktrace for when allocations and freeing of pages happened, provided the page_owner=on
boot params is present
Linux boot parameters
I’ve added the following boot parameters in /etc/default/grub
:
slub_debug=ZPU
, where Z stands for red zoning, P for poisoning and U for user tracking (a.k.a. stacktraces). More information on the slub_debug
options can be found here and for more in-depth information and examples see this article.
page_poison=on
, this turns on the CONFIG_PAGE_POISONING
kernel option mentioned above.
The SLUB memory manager in the linux kernel can also be used to detect things like double frees. See article for examples.
Ichor
As usual, I have to toot my own horn here. I have been working on Ichor for a couple years now. I started out trying to fix and prevent a lot of the pains I had during my career. Most of the mentioned tools, code patterns and compile flags have been incorporated into Ichor. The biggest thing that this article has not yet talked about, is threading issues: data races and deadlocks. At this point, the article is already very much larger than I had anticipated. There is a lot to say and a lot left to say. Ichor employs a code pattern that avoids threading issues, but definitely not up to the level of Rust. Perhaps I’ll write an article on that in the future.
Conclusion
Like I mentioned during the testing section, the combination of all these options cause me to find pretty much all my errors before I deploy anything to production.
Although C++ requires a lot of effort and knowledge to get safe, my personal experience with all of these options combined is that C++ can come pretty close to Rust in terms of memory safety.
What do you think? Is it worth it to spend time on making C++ more safe? What tools, flags or patterns do you use? Please let me know by sending an e-mail to michael (at) volt-software (dot) com.