I was quite annoyed by the top comment on this thread being "just use printf". This post is a more fleshed-out version of my comment on that thread.
Debuggers are drastically better than just using prints for many problems. An hour of research on this now will save you countless hours solving hard problems later.
In short, debuggers let you run your program in "human time" rather than ultra-fast computer time, giving you time to carefully inspect program state, control flow, and your assumptions.
This article serves as a stepping-off place for learning about debuggers.
The best graphical debugger is, without contest, Microsoft Visual Studio. You can debug C, C++, and C# programs with it (and maybe more). In this article I will refer to it as "MSVS". MSVS is the industry-standard C/C++ debugger for game development. Most graphical debuggers will feel familiar to MSVS.
Note that VSCode also has debugging capabilities, but it isn't nearly as streamlined or powerful as MSVS.
On Linux, GDB is the top dog. It's a command-driven debugger, but there are many front-ends that you can use to get a MSVS-like experience. VS Code has a GDB front-end. I use Emacs and GUD to help with debugging (run
gdb to start; I also prefer running
gdb has started).
Here is a list of features you should search and get familiar with using. Nearly every interactive debugger should support these fundamental features. You may need to look at your debugger's documentation. I assume an MSVS-like debugger for this article.
Breakpoints insert a special instruction that halts your program at that point in the code. You place a breakpoint as close to the problem as you can, so you can inspect program state.
Stepping is when you advance the program by one "step", usually one line of code. This is very useful because you can go line by line through your program, checking your watches/hovering your mouse over variables to see how the data is changing. "Step Into" means go into the function being called and step inside it. "Step Over" means don't step into that function, just run it and step to the next line. You usually Step Over functions you know are working/irrelevant to the problem.
Resumes the program execution. If you still have breakpoints, they may be hit again for the next iteration, game frame, etc.
Watches are added in MSVS via right-clicking a variable/statement and selecting "Add Watch". This displays the variable and its current value when the program is stopped.
You can modify watch statements to show you fields, the address of things, etc. by using the same syntax as the programming language, e.g.
myVar in watch, add
&myVar.field and it'll show you the address of
field instead. Hovering over a variable with your mouse usually shows a pop-up that you can use to explore all the fields and substructs in a variable.
In MSVS, you can use e.g.
myVar is an array to display 3 elements in the watch. Use
myvar-3 to decrement its address by 3. GDB has its own syntax for arrays, so refer to their documentation.
The above features form the core toolset of every interactive debugger. There are some advanced features that help solve trickier problems. I recommend you search e.g. "How to use conditional breakpoints in visual studio" and add these valuable tools to your toolset.
These take a memory address and a size. If memory at that address is changed by any code, the breakpoint will fire immediately after the change. This solves questions like "who the hell is changing this variable?" and assists with discovering things like memory stomps etc. It also helps narrow down tons of stepping, because you can set a data breakpoint to only notify you when there is change.
These only break if a condition you specify is met. In MSVS, you create these by right-clicking on an existing breakpoint and selecting "Add Condition". You can do e.g.,
bIsAttacking == true and the breakpoint will only be hit in that game state. This also saves you a lot of unnecessary stepping, if e.g. the thing only happens in certain states.
Moving the instruction pointer
In MSVS, you can drag around the little arrow pointing to the current line the debugger is stopped at. This allows you to repeat code so you can step through it again, or skip over blocks that aren't working yet.
MSVS has a "Memory" window that lets you see the raw memory at arbitrary addresses. This can be useful when working with optimized code, among other things.
For compiled languages, debuggers will often allow you to inspect and step at the assembly level. This is especially useful when working on program optimization.
Some things you need to be aware of, especially for C/C++ debugging:
- Your program becomes easier to debug when compiled in "Debug" mode
- You need "symbols" to easily debug. On windows, that means PDB files, on Unix-type systems, they're built into the executable/.so when compiling with
McConnell's Code Complete recommends stepping through any new code you add as a habit. This helps spot easy problems by walking through things line by line and making sure your assumptions/understanding of the program are correct. It greatly reduces the amount of iteration you have to do. I like doing this instead of getting my hopes up that a new block of code will work the very first time, which is unrealistic for most non-trivial implementation tasks.
John Carmack recommends occasionally stepping through an entire frame to get a better understanding of your game's execution:
An exercise that I try to do every once in a while is to "step a frame" in the game, starting at some major point like
renderer->EndFrame(), and step into every function to try and walk the complete code coverage. This usually gets rather depressing long before you get to the end of the frame. Awareness of all the code that is actually executing is important, and it is too easy to have very large blocks of code that you just always skip over while debugging, even though they have performance and stability implications.
Prints are still useful, sometimes
There are some problems that printf may be the better tool for:
- Real-time systems where stopping a program/thread to debug will change the behavior. In my experience, it is very rare that this is a problem. Things like audio and network connections with timeouts may be examples where outputting the data is better
- Very large data sets where only a small subset of the data may be anomalous, and you don't know how to formulate a Conditional or Data breakpoint to pinpoint that data (yet)
- Problems which only happen on a live service which cannot be stopped and debugged
Other debugging techniques
Visualization is also a valuable technique:
- Spatial 2D/3D math where drawing lines on the screen would be much easier than inspecting the numbers themselves. This is a case where interactive debuggers aren't the best
- Audio waveform graphs are usually more informative than raw numbers, and you generally can't step debug audio without changing the output
There are some programmers who don't believe debuggers are very useful (Walter Bright, the creator of the D programming language, for example). I think this is silly. There's no harm in learning to use a debugger, especially when even the most rudimentary use can be hugely valuable. It's another tool suitable for some subset of problem debugging, much like prints.