Original post

Need for a debugger

The simplest form of debugging in any programming language is by using print statements/logs and writing to standard out. This definitely works but becomes extremely difficult when the size of our application grows and the logic becomes more complex. Adding print statements to every code path of the application is not easy. This is where debuggers come in handy. Debuggers help us to trace the execution path of the program using breakpoints and a host of other features. Delve is one such debugger for Go. In this tutorial, we will learn how to Debug Go applications using Delve.

Installing Delve

Please ensure that you are inside a directory which doesn’t contain a go.mod file. I prefer my Documents directory.

cd ~/Documents/  

Next, let’s set the GOBIN environment variable. This environment variable specifies the location where the Delve binary will be installed. Please skip this step if you have the GOBIN already set. You can check whether GOBIN is set by running the command below.

go env | grep GOBIN  

If the above command prints, GOBIN="", it means that GOBIN is not set. Please run export GOBIN=~/go/bin/ command to set GOBIN.

Let’s add GOBIN to the PATH by running export PATH=$PATH:~/go/bin

In the case of macOS, the Xcode command line developer tools are needed to run Delve. Please run xcode-select --install to install the command line tools. Linux users can skip this step.

Now we are set to install Delve. Please run

go get github.com/go-delve/delve/cmd/dlv  

to install delve. After running this command, please test your installation by running dlv version. It will print the version of Delve on successful installation.

Delve Debugger  
Version: 1.4.0  
Build: $Id: 67422e6f7148fa1efa0eac1423ab5594b223d93b  

Starting Delve

Let’s write a simple program and then start debugging it using Delve.

Let’s create a directory for our sample program using the following command.

mkdir ~/Documents/debugsample  

Create a file main.go inside the debugsample directory we just created with the following contents.

package main

import (  
    "fmt"
)

func main() {  
    arr := []int{101, 95, 10, 188, 100}
    max := arr[0]
    for _, v := range arr {
        if v > max {
            max = v
        }
    }
    fmt.Printf("Max element is %dn", max)
}

The program above will print the biggest element of the slice arr. Running the above program will output,

Max element is 188  

We are now ready to debug the program. Let’s move to the debugsample directory cd ~/Documents/debugsample. After that, type the following command to start Delve.

dlv debug  

The above command will start debugging the main package in the current directory. After typing the above command, you can see that the terminal has changed to (dlv) prompt. If you can see this change, it means that the debugger has started successfully and waiting for our commands :).

Let’s fire our first command.

In the dlv prompt, type continue.

(dlv) continue

The continue command will run the program until there is a breakpoint or till completion of the program. Since we do not have any breakpoints defined, the program will run till completion.

Max element is 188  
Process 1733 has exited with status 0  

If you see the above output, the debugger has run and the program is completed :). But this was not of any use to us. Let’s go ahead and add a couple of breakpoints and watch the debugger do its magic.

Creating Breakpoints

Breakpoints pause the execution of the program at a specified line. When the execution is paused, we can send commands to the debugger to print the value of the variables, look at the stack trace of the program, and so on.

The syntax for creating a breakpoint is provided below,

(dlv) break filename:lineno

The above command will create a breakpoint at line lineno in the file filename.

Let’s add a breakpoint to line no. 9 of our main.go.

(dlv) break main.go:9

When the above command is run, you can see the output, Process 1733 has exited with status 0. The breakpoint was not added actually. This is because the program has exited when we ran continue earlier since there were no breakpoints at that time. Let’s restart the program and try setting the breakpoint again.

(dlv) restart
Process restarted with PID 2028  
(dlv) break main.go:9
Breakpoint 1 set at 0x10c16e4 for main.main() ./main.go:9  

The restart command restarts the program and then the break command sets the breakpoint. The above output confirms that the breakpoint with name 1 is set at line no. 9 in main.go.

Now let’s continue our program and check whether the debugger pauses the program at the breakpoint.

(dlv) continue

> main.main() ./main.go:9 (hits goroutine(1):1 total:1) (PC: 0x10c16e4)
     4:        "fmt"
     5:    )
     6:    
     7:    func main() {
     8:        arr := []int{101, 95, 10, 188, 100}
=>   9:        max := arr[0]
    10:        for _, v := range arr {
    11:            if v > max {
    12:                max = v
    13:            }
    14:        }

After continue is executed, we can see that the debugger has paused our program at line no 9. Just what we wanted :).

Listing breakpoints

(dlv) breakpoints

The above command lists the current breakpoints of the application.

(dlv) breakpoints
Breakpoint runtime-fatal-throw at 0x102de10 for runtime.fatalthrow() /usr/local/Cellar/go/1.13.7/libexec/src/runtime/panic.go:820 (0)  
Breakpoint unrecovered-panic at 0x102de80 for runtime.fatalpanic() /usr/local/Cellar/go/1.13.7/libexec/src/runtime/panic.go:847 (0)  
    print runtime.curg._panic.arg
Breakpoint 1 at 0x10c16e4 for main.main() ./main.go:9 (1)  

You might be surprised to see that there are two other breakpoints in addition to the one we added. The other two breakpoints are added by delve to ensure that the debugging session does not end abruptly when there is a runtime panic that is not handled using recover.

Printing variables

The program’s execution has paused at line no. 9. print is the command used to print the value of a variable. Let’s use print and print the element at the 0th index of the slice arr.

(dlv) print arr[0]

Running the above command will print 101 which is the element at the 0th index of the slice arr.

Do note that if we try to print max, we will get a junk value printed.

(dlv) print max
824634294736  

This is because the program has paused before line no. 9 is executed and hence printing max prints some random junk value. To print the actual value of max, we should move to the next line of the program. This can be done using the next command.

Move to next line in the source

(dlv) next

will move the debugger to the next line and it will output,

> main.main() ./main.go:10 (PC: 0x10c16ee)
     5:    )
     6:    
     7:    func main() {
     8:        arr := []int{101, 95, 10, 188, 100}
     9:        max := arr[0]
=>  10:        for _, v := range arr {
    11:            if v > max {
    12:                max = v
    13:            }
    14:        }
    15:        fmt.Printf("Max element is %dn", max)

Now if we try (dlv) print max we can see the output 101.

next command can be used to walk through a program line by line.

If you keep typing next, you can see that the debugger walks you line by line in the program. When one iteration of the for loop in line no. 10 is over, next will walk us through the next iteration and the program will terminate eventually.

Printing expressions

print can also be used to evaluate expressions. For example, if we want to find the value of max + 10, it’s possible using print.

Let’s add another breakpoint outside the for loop where the computation of max will be completed.

(dlv) break main.go:15

The above command adds another breakpoint to line no. 15 where the computation of max is finished.

Type continue and the program will stop at this breakpoint.

print max+10 command will output 198.

Clearing breakpoints

clear is the command to clear a single breakpoint and clearall is the command to clear all breakpoints in the program.

Let’s first list the breakpoints in our application.

(dlv) breakpoints

Breakpoint runtime-fatal-throw at 0x102de10 for runtime.fatalthrow() /usr/local/Cellar/go/1.13.7/libexec/src/runtime/panic.go:820 (0)  
Breakpoint unrecovered-panic at 0x102de80 for runtime.fatalpanic() /usr/local/Cellar/go/1.13.7/libexec/src/runtime/panic.go:847 (0)  
    print runtime.curg._panic.arg
Breakpoint 1 at 0x10c16e4 for main.main() ./main.go:9 (1)  
Breakpoint 2 at 0x10c1785 for main.main() ./main.go:15 (1)  

We have two breakpoints named 1 and 2

If we run clear 1, it will delete the breakpoint 1.

(dlv) clear 1
Breakpoint 1 cleared at 0x10c16e4 for main.main() ./main.go:9  

If we run clearall, it will delete all breakpoints. We have only one breakpoint named 2 remaining.

(dlv) clearall
Breakpoint 2 cleared at 0x10c1785 for main.main() ./main.go:15  

From the above output, we can see that the remaining one breakpoint is also cleared. If we executed continue command now, the program will print the max value and terminate.

(dlv) continue
Max element is 188  
Process 3095 has exited with status 0  

Step into and out of a function

It is possible to use Delve to step into a function or out of a function. Don’t worry if it doesn’t make sense now :). Let’s try to understand this with the help of an example.

package main

import (  
    "fmt"
)

func max(arr []int) int {  
    max := arr[0]
    for _, v := range arr {
        if v > max {
            max = v
        }
    }
    return max
}
func main() {  
    arr := []int{101, 95, 10, 188, 100}
    m := max(arr)
    fmt.Printf("Max element is %dn", m)
}

I have modified the program we have been using till now and moved the logic which finds the biggest element of the slice to its own function named max.

Quit Delve using (dlv) q, replace main.go with the program above and then start debugging again using the command dlv debug.

Let’s add a breakpoint at line. no 18 where the max function is called.

b is the shorthand for adding a breakpoint. Let’s use that.

(dlv) b main.go:18
(dlv) continue

We have added the breakpoint at line no.18 and continued the execution of the program. Running the above commands will print,

> main.main() ./main.go:18 (hits goroutine(1):1 total:1) (PC: 0x10c17ae)
    13:        }
    14:        return max
    15:    }
    16:    func main() {
    17:        arr := []int{101, 95, 10, 188, 100}
=>  18:        m := max(arr)
    19:        fmt.Printf("Max element is %dn", m)
    20:    }

The program execution has paused at line no. 18 as expected. Now we have two options.

  • Continue debugging deeper into the max function
  • Skip the max function and move to the next line.

Depending on our requirement we can do either. Let’s learn how to do both.

First, let’s skip the max function and move to the next line. To do this, you can just run next and the debugger will automatically move to the next line. By default, Delve doesn’t go deeper into function calls.

(dlv) next
> main.main() ./main.go:19 (PC: 0x10c17d3)
    14:        return max
    15:    }
    16:    func main() {
    17:        arr := []int{101, 95, 10, 188, 100}
    18:        m := max(arr)
=>  19:        fmt.Printf("Max element is %dn", m)
    20:    }

You can see from the above output that the debugger has moved to the next line.

Type continue and the program will finish executing.

Let’s learn how to go deeper into the max function.

Type restart and continue and we can see the program paused again at the already existing breakpoint.

(dlv) restart
Process restarted with PID 5378  
(dlv) continue
> main.main() ./main.go:18 (hits goroutine(1):1 total:1) (PC: 0x10c17ae)
    13:        }
    14:        return max
    15:    }
    16:    func main() {
    17:        arr := []int{101, 95, 10, 188, 100}
=>  18:        m := max(arr)
    19:        fmt.Printf("Max element is %dn", m)
    20:    }

Now type step and we can see that the control has moved into the max function now.

(dlv) step
> main.max() ./main.go:7 (PC: 0x10c1650)
     2:    
     3:    import (
     4:        "fmt"
     5:    )
     6:    
=>   7:    func max(arr []int) int {
     8:        max := arr[0]
     9:        for _, v := range arr {
    10:            if v > max {
    11:                max = v
    12:            }

Type next and the control will move to the first line of the max function.

(dlv) next
> main.max() ./main.go:8 (PC: 0x10c1667)
     3:    import (
     4:        "fmt"
     5:    )
     6:    
     7:    func max(arr []int) int {
=>   8:        max := arr[0]
     9:        for _, v := range arr {
    10:            if v > max {
    11:                max = v
    12:            }
    13:        }

If you keep typing next you can step through the execution path of the max function.

You might be wondering whether it is possible to return to main without stepping through each line in the max function. Yes, this is possible using the stepout command.

(dlv) stepout
> main.main() ./main.go:18 (PC: 0x10c17c9)
Values returned:  
    ~r1: 188

    13:        }
    14:        return max
    15:    }
    16:    func main() {
    17:        arr := []int{101, 95, 10, 188, 100}
=>  18:        m := max(arr)
    19:        fmt.Printf("Max element is %dn", m)
    20:    }

Once you type stepout, the control returns back to main. Now you can continue debugging in main :).

Printing stack trace

A very important functionality needed when debugging is to print the current stack trace of the program. This is useful to find out the current code execution path. stack is the command used to print the current stack trace.

Let’s clear all breakpoints add a new breakpoint at line no. 11 and print the current stack trace of the program.

(dlv) restart
(dlv) clearall
(dlv) b main.go:11
(dlv) continue

When the program is paused at the breakpoint, type

(dlv) stack

It will output the current stack trace of the program.

0  0x00000000010c16e8 in main.max  
   at ./main.go:11
1  0x00000000010c17c9 in main.main  
   at ./main.go:18
2  0x000000000102f754 in runtime.main  
   at /usr/local/Cellar/go/1.13.7/libexec/src/runtime/proc.go:203
3  0x000000000105acc1 in runtime.goexit  
   at /usr/local/Cellar/go/1.13.7/libexec/src/runtime/asm_amd64.s:1357

So far we have covered basic commands to help start debugging your application using Delve. In the upcoming tutorials, we will cover advanced features of Delve such as debugging goroutines, attaching the debugger to an existing process, remote debugging and also using Delve from the VSCode editor.

Thanks for reading. Please leave your comments and feedback.

Like my tutorials? Please support the content.