Debugging is one of the primary skills every computer science student should acquire. I want to provide an easy introduction to this topic, starring C++ and gdb, the debugger of the GNU project. In the following, I assume that the reader is somewhat familiar with the basic concepts of C++ programming.

A simple example

Let’s assume that we want to transform a string into a palindrome and write the following program to do so:

#include <iostream>
#include <string>

std::string palindrome( std::string s )
{
  for( auto i = s.size() - 1; i >= 0; i-- )
  {
    if( s[i] != s[s.size() - i - 1] )
      s[s.size() - i - 1] = s[i];
  }

  return s;
}

int main()
{
  std::cout << palindrome( "Hello, World!" ) << std::endl;
}

We compile the program using

$ g++ -std=c++11 foo.cc

and run ./a.out just to find out that the program crashes with a segmentation fault. Bummer. Let’s recompile the program and prepare it for debugging. To this end, we just add the -g switch on the command-line for the compiler. This ensures that debug symbols are included. Loosely speaking, these symbols are required for the debugger to connect the binary executable with our source code.

So, we issue

$ g++ -std=c++11 -g foo.cc

and run gdb with our executable as an argument:

$ gdb a.out

This should result in output that is somewhat similar to this:

GNU gdb (GDB) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
---Type <return> to continue, or q <return> to quit---
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
---Type <return> to continue, or q <return> to quit---
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from a.out...done.
(gdb)

The most important thing is that gdb greets us with its own prompt, meaning that from now on, we directly interact with the debugger instead of running the program directly.

We first run the binary to find out what happens:

(gdb) run

This should yield an output similar to

Program received signal SIGSEGV, Segmentation fault.
0x0000000000400bd4 in palindrome (s="!dlroW World!") at foo.cc:8
8       if( s[i] != s[s.size() - i - 1] )

and send us right back to the prompt. We can see that our program caused a segmentation fault. This error usually indicates that we accessed memory that does not belong to our program somewhere along the way.

How can we find out more about what went wrong? We first take a look at the backtrace of the error. Roughly speaking, these are all the functions that are currently active—currently meaning at the time of the error.

To find out more about the backtrace, we type bt or backtrace:

(gdb) bt

This gives us lines like these:

#0  0x0000000000400bd4 in palindrome (s="!dlroW World!") at foo.cc:8
#1  0x0000000000400c65 in main () at foo.cc:17

Not too surprisingly, the last function, i.e. function #2, is the main function of our program. After that, our palindrome function was called. Each of the lines in this list is referred to as a frame. To see a specific frame—i.e. a certain function along with its code—we type frame NUMBER or f NUMBER.

Let us first take a look at the main function:

(gdb) frame 1

This gives us some code:

#1  0x0000000000400c65 in main () at foo.cc:17
17    std::cout << palindrome( "Hello, World!" ) << std::endl;

We can see the line number (17) and some of the parameters of the function. Furthermore, we see the next function call, viz. palindrome, which in turn causes the segmentation fault.

Since the line looks relatively safe, there must be an issue with the variables that are used to access the characters of the string. So, let’s get back to the first frame:

(gdb) frame 0
#0  0x0000000000400bd4 in palindrome (s="!dlroW World!") at foo.cc:8
8       if( s[i] != s[s.size() - i - 1] )

Again, the if condition seems relatively safe. To cause a segmentation fault, we must access some bad memory somewhere. Thus, let us take a look at the values of the variables at this point. To this end, we can use the print command or p for short.

Let us begin with the string itself:

(gdb) print s
$1 = "!dlroW World!"

This seems to be correct so far. There is certainly nothing wrong here. So let us try the loop variable next:

(gdb) print i
$2 = 18446744073709549580

Alright. I am fairly confident that our string is not that long. So, we have identified the culprit—something is going wrong with the loop counter. At this point, we could use the edit command to open the file in our favourite editor and add some std::cout statements.

Yes, I am serious. std::cout can be a very powerful debugging tool! As long as we give some serious thought to identifying potential places in our code that may cause an error, there is nothing wrong with sprinkling some printing statements over your code. Of course we could also set breakpoints and watch how the value of i changes over the course of the program. But this is more advanced stuff, so I will rather cover it in another article.

So, let us assume that we have added enough std::cout statements. Running the program with these statements quickly shows that we are traversing the string incorrectly. As s.size() is always unsigned, the condition i >= 0 in the loop always evaluates to true. By traversing the string in a forward manner, e.g.

for( std::size_t i = 0; i < s.size(); i++ )

we finally get our program to work, resulting in the beautiful output of

Hello, ,olleH

Fun with make

If you have a Makefile—or if you are using CMake, for example—the debugger offers you a very cool integrated workflow. You can directly use edit to work on the code. Afterwards, you can type make to recompile the program, followed by run to check its behaviour again. For easily-fixable errors, this means that you never even have to leave the debugger. More about this in a subsequent article.

Conclusion

In the next article, we shall see how to combine debugging with make. Also, I want to show you the wonderful world of breakpoints. Until that time, here are the main lessons of debugging:

  • Try to figure out what causes the error
  • Always assume that your code is at fault
  • Do not be afraid of using std::cout or printf or whatever for debugging. Whatever works.
  • Always compile with extra warnings enabled. By compiling with -Wall -Wextra, the compiler already helps you catch a lot of potential issues. In our case, for example, the compiler would have complained with comparison of unsigned expression >= 0 is always true, which would have already caught the error before debugging.

Happy bug-fixing!