July 26th, 2024

How to build highly-debuggable C++ binaries

David Hashe's article offers guidance on configuring C++ toolchains for better debuggability, emphasizing practices like enabling sanitizers, using debug modes, and balancing performance with debuggability in large projects.

Read original articleLink Icon
AppreciationFrustrationCuriosity
How to build highly-debuggable C++ binaries

The article by David Hashe provides actionable advice on configuring C++ toolchains to produce highly-debuggable binaries. It highlights the complexities of C++ compilation and the lack of standard build tools, which can complicate debugging. Hashe emphasizes the importance of understanding the compilation model and recommends using the project's preferred dependency management system. He discusses the merits of interactive debugging versus printf-style debugging, suggesting that interactive debugging is generally more beneficial for large legacy projects.

To create highly-debuggable binaries, Hashe outlines several key practices: enabling sanitizers, using debug modes for standard libraries, generating debugging information for macros, and compiling with frame pointers. He also advises enabling asynchronous unwind tables for precise stack unwinding and ensuring that static libraries are fully linked. The article categorizes the advice into general compilation changes, semi-specific changes, and specific source code modifications.

Hashe stresses the need to balance debuggability and performance, suggesting that developers should decide in advance which parts of the binary will be debuggable based on the specific bug being addressed. He concludes that while C++ debugging cannot match the ease of scripting languages, targeted changes and custom debugger extensions can significantly enhance the debugging experience. The guide is tailored for g++ and clang++ on x86_64 GNU/Linux with gdb, although some advice may apply to other platforms.

AI: What people are saying
The comments on David Hashe's article reflect a variety of insights and experiences related to C++ debugging practices.
  • Many commenters emphasize the importance of enabling frame pointers and the benefits they bring to debugging.
  • There is a discussion about the use of sanitizers like ASAN in production, weighing their advantages against potential security risks.
  • Several users share personal experiences and techniques for improving stack trace generation and debugging efficiency.
  • Comments highlight the challenges of debugging with modern C++ features, particularly with template-heavy libraries.
  • There is a consensus on the need for better tools and practices to enhance debuggability in C++ projects.
Link Icon 17 comments
By @amluto - 4 months

    if (breakpoint_1 && (x_id == 153827)) {
        __asm("int $3");
    }
No, don’t do it quite like that. Do:

    __asm(“int3\n\tnop”);
int3 is a “trap”, so gdb sees IP set to the instruction after int3: it’s literally the correct address plus one. gdb’s core architecture code is, to be polite, not very enlightened, and gdb does not understand that int3 works this way. So gdb may generate an incorrect backtrace, and I’ve even caught gdb completely failing to generate a trace at all in some cases. By adding the nop, IP + 1 is still inside the inline asm statement, which is definitely in the same basic block and even on the same line, and gdb is much happier.
By @mark_undoio - 4 months
Good to see this discussed - debuggability is not talked about enough but, done right, it could be a superpower.

Setting the build for an old x64 machine (https://dhashe.com/how-to-build-highly-debuggable-c-binaries...) for reversible / time travel debuggers seems unnecessarily restrictive to me. I'd expect a modern time travel debug tool (e.g. either rr or Undo - disclaimer, which I work on) to cope fine with most modern instructions (I believe GDB's built-in record / replay debugging tends to be further behind the curve on new CPU instructions - but if you're doing anything at scale it's not the right choice anyhow).

Regarding compilation (https://dhashe.com/how-to-build-highly-debuggable-c-binaries...) - we generally advise customers to use -Og rather than -O0. As the article states, this will still optimise out some code but should be a good trade-off without being too slow. (NB. last I checked, clang currently uses -Og as an alias for -O1, so it may behave less satisfactorily than under GCC).

It's also not said enough but: you don't need a special debug build to be able to debug. It's less user-friendly to debug a fully-optimised release build but it's totally possible. You just need to retain the DWARF debug info (instead of throwing it away). This is really important to know if you're debugging on a customer system or analysing a bug that's only in release builds.

By @ggambetta - 4 months
About a million years ago (OK, more like 20) I was making casual videogames in C++ and I wanted a cross-platform (Linux, Mac, Windows) way to get a stack trace whenever a game crashed. What I ended up doing was adding a macro to the first line of every function, let's call it STACKTRACE, which was something like

  #define STACKTRACE GLOBAL_STACK_FILE[GLOBAL_STACK_IDX] = __FILE__; GLOBAL_STACK_LINE[GLOBAL_STACK_IDX++] = __LINE__; StackTraceCleaner stc;
StackTraceCleaner was a class that didn't do anything but execute GLOBAL_STACK_IDX-- in its destructor.

So at any point in time I could inspect GLOBAL_STACK_FILE and GLOBAL_STACK_LINE and have a complete stack trace of the game.

Obviously this only worked because these games weren't performance-critical and because they were essentially single-threaded, but it did the job at the time. We're talking about a time when Visual Studio 6's support for templates was half-broken, and the STL wasn't exactly S, to the point that I had to roll out my own string, smart pointers, containers, etc -- made twice as hard because of the aforementioned broken template support in VS6 :(

I do miss these simpler, more innocent times, though.

By @Const-me - 4 months
Tangentially related, a few tips about offline debugging on Windows: http://const.me/articles/windbg/windbg-intro.pdf

Not a silver bullet but still, being able to collect and analyze user-mode crash dumps is sometimes the best way to investigate and fix bugs.

By @renox - 4 months
> Avoid stepping into irrelevant code

Thanks a lot!! I don't know how many times I've stepped into the C++ standard library and it gets really annoying..

By @binary132 - 4 months
I particularly liked the point that not every TU needs to be compiled in debug mode. I am working on a build system and now I’m thinking of setting aside some time to make sure debug and optimization is an object level option. In general, I think the usefulness of a well specified ABI over object code is vastly underappreciated!
By @Galanwe - 4 months
What irks me the most is that in 2024, I still can't reliably embed source code in dwarf5 to get meaningful source-contextualized stacktraces and have to ship source code separately and override the source mapping.
By @breatheoften - 4 months
I recently added some pretty printers for a type we have a lot of in our codebase (the c++ Eigen library).

Unlike the article we are using lldb rather than gdb ... and while I appreciate thats its possible _at all_ to script the debugger to do some pretty printing -- I found it quite a bit more frought to implement than initially expected ...

To take the Eigen example ... Eigen is a 'header only' library and offers Templated vector and matrix types. The types are template over (optionally), data type, number of rows, number of columns, and matrix row order (row major or column major). All that information is not actually even available at runtime -- just the type name (with the instantiated values for template arguments) ...

I ended up having to super hackily parse information out of the template type name in order to be able to pretty print the matrix appropriately in lldb ...

Problems of this nature abound when debugging c++ ... Very often with a header only library, there isn't even a symbol for methods you might want to call -- so you want to eg, call the size() method on some object within the debugger to see how big it is, you'll often be out of look due to an undefined symbol reference since the 0-overahead compilation models ensures the symbol doesn't even have to be created in the binary ...

Would be nice if there was some kind of way around that -- I guess I need to try the workaround mentioned in the article of explicitly instantiating template classes for common classes in 'debug' mode ... My fuzzy mental model derived from previous experience somehow doesn't think that will actually help the issue tho -- but I'd be happy to be wrong!

By @forrestthewoods - 4 months
Great post. I’m surprised it requires so much effort. On Windows you pretty just need to make a debug build and… that’s it!

A nice trick with MSVC is you can turn off optimizations for TU or any block of code with:

    #pragma optimize( "", off )
Leaps and bounds easier than hacking the build the system.
By @rramadass - 4 months
One more technique is to instrument the build process for gprof/gcov to generate the runtime call graph. Ignoring the timings; With these call trees in hand for categories of inputs, i have found it easier to figure out code paths in a new codebase.
By @hurpdurpdurp - 4 months
Great article with lots of good advice, but it makes me wonder what the consensus is on using ASAN in production.

Once upon a time it was widely said that ASAN should not be used for production code. The authors advocated against it and from a general-purpose security perspective it gives attackers a very large writable memory region at a fixed offset to play with. But over time I see more and more ASAN code in production on the theory that ASAN may make a system easier to exploit, but a memory corruption will make it easier to exploit. And so it's better to have knowledge of the issue.

Also, I've personally found the glibc malloc tunables very useful for debugging.

By @weinzierl - 4 months
Good points, but for me number one would be to avoid runtime polymorphism like the plague.

If your call graph has more roots than your neighbors garden and the whole thing is a forest and not a tree you will have a hard time understanding, analyzing and ultimately debugging.

By @jeffbee - 4 months
Anyone have tips on getting good stack traces in opt builds? I am really struggling with it at the moment. LLVM sanitizers all generate brilliant stack traces by forking llvm-symbolizer and feeding it the goods. But during runtime crashes on optimized binaries I don't seem to get good stack traces. One of the problems is that some library backtrace functions do not print the base address of the DSO mapping, which means they are printing a meaningless PC that can't be used to find file and line later.
By @teleforce - 4 months
>Enable frame-pointers for all functions

>Compile with frame-pointers.

It's good to see that enabling frame pointers are included in the recommendations for debugging purposes.

The discussions on the relevance and the usefulness of frame pointers earlier this year on HN [1]:

[1] The return of the frame pointers:

https://news.ycombinator.com/item?id=39731824

By @dataflow - 4 months
In case the author is here: I see --gdb3 in a lot of places on the post where I think they meant --ggdb3.
By @account42 - 4 months
> Enable "debug mode" or "debug hardening" within your stdlib

> If using libc++:

> Add this define to your CXXFLAGS: -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG

... until the bikeshedding bastards change the define yet again in the next release.