Compile Once Debug Twice: Picking a Compiler for Debuggability (1/3)

Have you ever had an assert get triggered only to result in a useless core dump with missing variable information or an invalid callstack? Common factors that go into selecting a C or C++ compiler are: availability, correctness, compilation speed and application performance. A factor that is often neglected is debug information quality, which symbolic debuggers use to reconcile application executable state to the source-code form that is familiar to most software engineers. When production builds of an application fail, the level of access to program state directly impacts the ability for a software engineer to investigate and fix a bug. If a compiler has optimized out a variable or is unable to express to a symbolic debugger how to reconstruct the value of a variable, the engineer’s investigation process is significantly impacted. Either the engineer has to attempt to recreate the problem, iterate through speculative fixes or attempt to perform prohibitively expensive debugging, such as reconstructing program state through executable code analysis.

Debug information quality is in fact not proportionally related to the quality of the generated executable code and wildly varies from compiler to compiler. This blog post compares debug information quality between two popular compilers: gcc and clang. In this blog post, we will introduce the topic of optimization and highlight examples of their impact on debuggability. This blog post is part of a longer series, in the next blog post we’ll do finer grained analysis directly comparing gcc and clang in real world and synthetic programs.

Introduction

A compiler compiles source-code into executable code that interacts with memory, a limited set of registers and simplistic control structures such as conditional jumps. A compiler also emits debug information that enables a symbolic debugger to map the state of memory and registers back to a representation that includes source-code structure, variables and types. The format of this debug information is complex because it is designed to be as flexible as possible in order to support most programming languages, architectures and compiler optimizations. The format is actually turing-complete! If you want to learn more about debug formats, then I recommend the following resources:

The examples below are compiled with -O2 optimization levels.

Optimizations and Debug Quality

Different compilers emit debug information at varying levels of quality and accuracy. However, certain optimizations will certainly impact any debugger’s ability to generate accurate stack traces or extract variable values. This section briefly touches on some of these optimizations.

Variables and Optimization

The compiler’s register allocator is responsible for allocating a larger number of program variables to a smaller set of processor registers. Register accesses are significantly faster than memory accesses, but the number of registers are scarce. Executable code will juggle between spilling (writing values from register to memory) and filling (reading from memory into registers) to efficiently make use of these registers. The value of a variable may exist in a register, in memory, a combination of the two or in debug information if it is a constant. If a compiler detects that the value of a variable is no longer needed (see live variable analysis), then the generated executable code may not save the value of the variable. In this situation, the variable is optimized out and the value is irretrievable.

Take the following program:

In the above program, the value of argv is extracted and then the program is paused. The ck_pr_load_ptr function performs a read from the region of memory pointed to by argv, in a manner that prevents the compiler from performing optimization on it. This ensures that the memory access occurs and for this reason, the value of argv must be accessible by the time ck_pr_load_ptr is executed.

When compiled with gcc, the debugger fails to find the value of the variable. The compiler determines that the value of argv is no longer needed after the ck_pr_load_ptr operation and so doesn’t bother paying the cost of saving the value.

However, if we modify the program to the following:

The debugger is able to successfully extract the value, as seen below.

The executable code will also ensure the value of argv is saved and restored. In this particular situation, when main is called, the value of argc is in the %rsi register. The compiler will save the value of %rsi in the %rbx register, whose value pause would be required to restore prior to return.

Call stack and Optimization

Some optimizations generate executable code whose call stack cannot be sufficiently disambiguated to reconcile a call stack that mirrors that of the source program. Two common culprits for this are tail call optimization and basic block commoning.

Basic Block Commoning

Let’s examine how basic block commoning impacts the accuracy of extracting stack traces from the following program.

If the program receives a first argument of 1, then function is called with the argument of "a". If the program receives a first argument of 2, then function is called with the argument of "b". However, if we compile this program with clang, the stack traces in both cases are identical! clang informs the debugger that the function f invoked the function("b") branch where x = 2 even if x = 1.

With common block elimination, the compiler may combine the branches into function into a single instruction. This means the stack is unwound to the same instruction in both cases (identical line numbers in f regardless of whether "a" or "b" is provided as input).

Tail call optimization

If the last operation executed in a function is a call to another function, the compiler may have the executable code jump into the target function without allocating additional stack space. In certain situations, this will mean the debugger will not have sufficient information to unwind the function call stack. Take the following program, where the function factorial is implemented in tail recursive form.

When compiled with optimizations on in both gcc and clang, the debugger reports the following call stack:

The call stack should actually look like the following:

The compilers were smart enough to eliminate the tail call and inline the function into the following loop:

The emitted debug information contains both information about the caller and the inlined instance of the function. This is insufficient to reconstruct a call stack with associated state. In this case, the debugger is only able to disambiguate the innermost invocation of the function call.

Debug Information Quality

Though some optimizations will certainly impact the accuracy of a symbolic debugger, some compilers simply lack the ability to generate debug information in the presence of certain optimizations. One common optimization is induction variable elimination. A variable that’s incremented or decremented by a constant on every iteration of a loop or derived from another variable that follows this pattern, is an induction variable.

Take the following snippet.

This function will return the count of “w” characters in a string as seen below.

In this particular case, the function is invoked using:

Coupled with other optimizations, the compiler is then able to generate code that doesn’t actually rely on a dedicated counter variable “i” for maintaining the current offer into “buffer”. An approximate semantic mapping from the source-code to the generated executable code is below.

As you can see, i is completely optimized out. The compiler determines it doesn’t have to pay the cost of maintaining the induction variable i. It maintains the pointer in the register %rdi. The code is effectively rewritten to something closer to this:

Both gcc and clang will end up generating similar executable code for this program. Debug information must support aggressive compiler optimizations and for that reason is highly expressive. For example, let’s look at the debug information generated by gcc (using the dwarfdump tool).

The highlighted line indicate how to interpret the current state of registers to extract the value of variable i when the instruction pointer is pointing between memory addresses 4008cc and 4008ce. The highlighted line below is the instruction at address 4008cc.

The debug information (in the DWARF format) expresses the value of i using a state machine. The highlighted debug information in the first screenshot expresses that the debugger should push the value of the rdi register onto the stack, then the value of the rdx register, subtract the two, and then add the value 4095 to find the value of i. Note that the debug information does not describe the value of i in all regions of executable code where it is live (meaning, a debugger would be unable to retrieve the value). clang on the other hand is unable to express this and for some variables, may simply provide invalid information rather than indicate that the value is optimized out. See below for an invocation of a debugger on versions of this program compiled under gcc and clang.

gcc is able to recover the value of the i variable depending on the instruction being executed by the program at the time a debugger attempts to extract its value. clang on the other hand has erroneous debug information and expresses the values of both sum and i as a constant of 0.

Beyond optimizations, clang is unable to express certain data types with optimizations turned on such as bit fields. A more exhaustive comparison between the two compilers will be presented in an upcoming blog post.

Conclusion

We have shown some common optimizations that may get in the way of the debuggability of your application and demonstrated a disparity in debug information quality across two popular compilers. In the next blog post of this series, we will examine how gcc and clang stack up with regards to debug information quality across a myriad of synthetic applications and real world applications.

If you’re interested in better debugging capabilities for your applications including C++ crash reporting and native crash reporting, check us out at https://backtrace.io.

Follow me on Twitter at @0xf390.

By | 2017-08-24T16:52:03+00:00 August 24th, 2017|Engineering|