Introduction

Jamie Brandon of scattered-thoughts.net recently put out a blog post called “Looking for Debugger”. In it, he goes through all of the debuggers and debugger frontends on Linux that he knew of and details their deficiencies from his perspective and asks for help finding a good set of tools.

This is my answer, and here’s a sneak peek (click for full size):

Sneak peek of my dev environment

For those wondering, yes, I have a 4K screen. And it’s 43 inches.

Tools

First of all, while I am going to attempt to not have the same touch of arrogance as others who say, “Linux is my IDE,” Linux and the tools available on it are, in fact, my IDE.

Here are the tools that matter for debugging:

Neovim

Neovim is my editor of choice. Neovim is just a better vim (in my opinion); vim itself should work.

In fact, vim had a feature I wanted before Neovim did: opening a terminal window vertically. However, the Neovim folks were kind enough to accept a PR from me.

The important part about Neovim (and vim) is that they have support for debugging inside of the editor itself. They call it termdebug. It is this that allows me to have an IDE-like experience without the IDE.

Thus, Neovim is the keystone of my debugging setup.

The relevant parts of my 764 line(!) Neovim config are:

packadd termdebug
let g:termdebug_wide = 1

tnoremap <Esc> <C-\><C-n>

nnoremap <RightMouse> :Evaluate<CR>

command GdbStart :call TermDebugSendCommand('start')
command GdbUp :call TermDebugSendCommand('up')
command GdbDown :call TermDebugSendCommand('down')
command GdbSaveBreakpoints :call TermDebugSendCommand('sb')
command GdbLoadBreakpoints :call TermDebugSendCommand('lb')
command GdbQuit :call TermDebugSendCommand('quit')

nnoremap <F1> :Gdb<CR>
nnoremap <F2> :Program<CR>
nnoremap <F3> :Continue<CR>
nnoremap <F4> :Over<CR>
nnoremap <F5> :Step<CR>
nnoremap <F6> :Finish<CR>
nnoremap <F7> :GdbUp<CR>
nnoremap <F8> :GdbDown<CR>
nnoremap <F9> :GdbLoadBreakpoints<CR>
nnoremap <F10> :GdbSaveBreakpoints<CR>
nnoremap <F11> :Break<CR>
nnoremap <F12> :Clear<CR>

nnoremap <Leader>. :Termd<Up><CR>
nnoremap <C-e> :GdbStart<CR>
nnoremap <Leader>q :GdbQuit<CR>
nnoremap <Leader>' :Source<CR>

The first two lines tell Neovim to enable terminal debugging and to open the terminal vertically. Line 4 lets me use the <Esc> key to exit insert mode in the terminal, and line 6 lets me use the right mouse button to evaluate an item. (I don’t use that feature often.)

The rest of the commands set up commands keys for easy debugging.

All of my bindings are heavily dependent on my keyboard (a keyboard.io Model 01), so you will probably want to heavily modify them.

The way it is set up for me, I just have to run :TermdebugCommand <command> once, and then when I want to debug the same command again, I just have to press <Leader>.. (My <Leader> is <Space>.) Then I press <C-e> to start debugging. My particular setup (Dvorak) means that whole sequence is <Space>+e, <Ctrl>+e since the period on Dvorak is where e is on Qwerty.

The :Source command tells Neovim to focus on the source pane (the far left of the sneak peek picture). The :Gdb command tells Neovim to focus on the gdb window (bottom left center in the sneak peek), and the :Program command tells it to focus on the program output window (top left center in the sneak peek). More on all of that later.

The :GdbLoadBreakpoints and :GdbSaveBreakpoints commands are my own; more on them later.

The rest of the commands should be obvious to anyone who knows how to run gdb on the command-line.

As an extra feature, I have the following in my Neovim config:

nnoremap <Leader>sa :mksession! .session.vim<CR>
vnoremap <Leader>sa <Esc>:mksession! .session.vim<CR>v
nnoremap <Leader>so :source .session.vim<CR>
vnoremap <Leader>so <Esc>:source .session.vim<CR>v

as well as the following in my $HOME/.gitignore:

.session.vim

This lets me save a development session and open it later, which gives Neovim a more IDE-like feel.

bash

The relevant parts of my .bashrc are:

export GDBSETUP=".gdbsetup"

setupgdb()
{
	if [ -e "$GDBSETUP" -a ! -f "$GDBSETUP" ]; then
		printf '%s already exists and is not a file\n' "$GDBSETUP"
		exit 1
	fi

	local _setupgdb_tty=$(tty)

	printf 'dashboard -output %s\n' "$_setupgdb_tty" > "$GDBSETUP"
}

This creates a command setupgdb that sets up a terminal as output for gdb-dashboard. Speaking of which…

gdb-dashboard

If Neovim is the keystone of my setup, then gdb-dashboard is the paint and decorations that makes it nice to use.

gdb-dashboard has to be one of the best command-line tools I have ever used, and it looks nice too! In the sneak peek picture, it is what is running the entire right half of my screen.

gdb-dashboard is a .gdbinit file. It is basically a set of commands and pre-defined settings for gdb to create a plugin.

If you already have a custom .gdbinit, do not fear; gdb-dashboard has a way to import your own stuff.

To install gdb-dashboard, just copy the .gdbinit to your home directory.

If you want to add to gdb-dashboard, create a .gdbinit.d/ directory in your home directory and put all of your stuff in there. Python files are loaded first, in alphabetical order, then all other files are loaded, in alphabetical order.

I have five files in .gdbinit.d/:

  • function.py
  • modules.py
  • auto
  • hooks
  • init

functions.py

This contains any Python functions I use in my gdb settings. At the moment, it contains this:

import os

class FileExists(gdb.Function):
    """Returns true if a file exists, false otherwise."""

    def __init__(self):
        super(FileExists, self).__init__ ("file_exists")

    def invoke(self, name):
        try:
            namestr = name.string()
            ret = os.path.isfile(os.path.abspath(namestr))
        except:
            ret = False
        return ret

FileExists()

modules.py

This file contains my custom modules for gdb-dashboard. I have two: a Breakpoints module and a custom Backtrace module (gdb-dashboard includes a Stack module that I wasn’t quite satisfied with).

This file is over 300 lines long, so I won’t paste it here. However, you can download it here.

auto

This file sets up gdb-dashboard itself.

if $file_exists("./.gdbsetup")
    source .gdbsetup
end

dashboard -enabled on
dashboard -layout threads breakpoints assembly expressions !history !memory registers !source variables !stack backtrace
dashboard -style prompt 'gdb>'
dashboard -style max_value_length 0
dashboard variables -style compact False
dashboard variables -style align True
dashboard variables -style sort True
dashboard expressions -style align True
dashboard stack -style sort True

define expand
    dashboard -style compact_values False
end

define pack
    dashboard -style compact_values True
end

expand

The first three lines sources the file created by the setupgdb command I made for bash. What it does is tell gdb-dashboard which terminal to output to. If I didn’t have that, gdb-dashboard would just output to its own terminal, crowding out everything in that tiny space.

The next set of lines basically set up my defaults for gdb-dashboard, including which modules are shown and which are not. It sets a custom prompt as well.

Then I define two commands, expand and pack. These control how gdb-dashboard outputs data. (We will see those in use later.) Then I use expand to set a default.

hooks

gdb has hooks, which allows users to set commands that are always run before or after a command. I use them to make sure that gdb-dashboard is always run after every command.

The file is simple, though repetitive:

define hookpost-up
    dashboard
end

define hookpost-down
    dashboard
end

define hookpost-frame
    dashboard
end

define hookpost-finish
    dashboard
end

define hookpost-return
    dashboard
end

define hook-quit
    set confirm off
end

define hookpost-enable
    dashboard
end

define hookpost-disable
    dashboard
end

define hookpost-break
    dashboard
end

define hookpost-tbreak
    dashboard
end

define hookpost-delete
    dashboard
end

define hookpost-watch
    dashboard
end

define hookpost-rwatch
    dashboard
end

define hookpost-awatch
    dashboard
end

define hookpost-condition
    dashboard
end

define hookpost-core-file
    dashboard
end

define hookpost-thread
    dashboard
end

init

This file is the last one that is run, and it only does a few things.

set print pretty on

define lb
    set breakpoint pending on
    source .gdbbreakpoints
    echo breakpoints loaded\n
    set breakpoint pending auto
end

define sb
    save breakpoints .gdbbreakpoints
    echo breakpoints saved\n
end

First, it tells gdb to make sure pretty printing is on. Then it defines two commands, one to save the current set of breakpoints to a file, and one to load them.

Do you remember my :GdbLoadBreakpoints and :GdbSaveBreakpoints commands in Neovim? They correspond directly with the commands lb and sb defined in init. This allows me to save the breakpoints that I used in a debugging session and reuse them later. This reduces the amount of busywork I have to do to get gdb back to where it was the last time I used it.

gdb

Of course, if I am going to use gdb-dashboard, it kinda makes it necessary to use gdb. In fact, so much of this setup, including gdb-dashboard, is dependent on gdb’s Python API.

But the files I use are all defined under gdb-dashboard, so I will quickly move to…

tmux

If Neovim is the keystone of my setup, and gdb-dashboard is the paint, then tmux is the foundation.

tmux is the tool that allows me to basically turn my terminal into a replacement for an IDE-like experience.

My .tmux.conf is mostly about plugins and looks, which don’t matter as much, but you can see it here.

However, that is not all I use for tmux. I also have some more code in my .bashrc to use tmux:

if `command -v tmux > /dev/null`; then
	[ -z "${TMUX+set}" ] || export SESSION=`tmux display-message -p '#S'`
fi

function quit
{
	if `command -v tmux > /dev/null`; then
		tmux kill-session -t $SESSION
	fi
}

function killdetached
{
	tmux list-sessions | grep -E -v '\(attached\)$' - | while IFS='\n' read line; do
		line="${line#*:}"
		tmux kill-session -t "${line%%:*}"
	done
}

quit allows me to quit an entire tmux session in one command, and killdetached is for when I have rogue tmux sessions running. And it defines $SESSION if the terminal is inside a tmux session.

tmuxp

The reason tmux works as the foundation, is because of tmuxp. tmuxp allows me to automate the startup of all of these tools. If it weren’t for tmuxp, there would be too much busywork in making everything work.

I have a folder in my home directory, .tmuxp/, where I store all of my project configs. Here are a couple examples:

session_name: blog
start_directory: /home/gavin/<directory_hidden>/blog

windows:
  - window_name: editor
    layout: main-vertical
    panes:
      - vim
  - window_name: terminal
    layout: main-vertical
    panes:
      -
      - hugo serve --path-warnings --debug -D --disableFastRender --renderToDisk --forceSyncStatic --noHTTPCache --ignoreCache

The one above is for my blog. You can see that I open two windows, one for the editor (Neovim, which is soft-linked to vim on my machine), which takes the entire screen. The next window has two panes, one of which is empty (for any commands I need to run by hand), and the other runs Hugo on my blog to let me see the draft(s) in progress.

session_name: bc
start_directory: /home/gavin/<directory_hidden>/bc

windows:
  - window_name: editor
    layout: main-vertical
    panes:
      - vim
  - window_name: terminal
    layout: main-vertical
    panes:
      -
      -
  - window_name: debug
    layout: main-vertical
    panes:
      - vim
      - setupgdb

This one is for my bc, which you saw in the sneak peek.

It has three windows. The first is for the editor, like my blog. The second is two panes for running commands. The third is where the magic happens.

The third has two panes, one of which runs Neovim, and one of which runs my setupgdb command from earlier. Thus, after telling tmuxp to start my bc session, gdb-dashboard is already set up to output to a separate terminal, and I have Neovim running to start the debugger.

But the niceties that tmuxp can give me don’t stop there.

I wrote the following and put it into my .bashrc:

eval "$(_TMUXP_COMPLETE=source tmuxp)"

_tmuxp_project_completions() {
	local word
	local files=$(ls $HOME/.tmuxp/${COMP_WORDS[1]}*.yaml 2> /dev/null)
	for f in $files; do
		COMPREPLY+=($(basename "$f" .yaml))
	done
	if [ "$COMP_CWORD" -gt 1 ]; then
		local offset=0
		for (( i=1; i < COMP_CWORD; i++ )); do
			word="${COMP_WORDS[i]}"
			if [ "$word" != -* ]; then
				offset=$(printf "$i + 1" | bc)
				break
			fi
		done
		if [ $offset -ne 0 ]; then
			COMPREPLY=()
			_command_offset "$offset"
		fi
	fi
}

alias mux='tmuxp load'
complete -F _tmuxp_project_completions mux

First, I run a command that tmuxp requires. Then I define a bash completion function that basically searches my $HOME/.tmuxp folder for configs. Then it uses those names, without the .yaml extensions, to do completions.

After that, I define mux as an alias for tmuxp load and use the completion function for mux.

What It Looks Like

Here is what a development session looks like.

First, I open a terminal (fullscreen kitty for the curious) and run mux <session_name>, mux bc in this case.

Opening a terminal and running mux bc.

Once tmuxp loads tmux, I am greeted by Neovim.

Looking at Neovim in tmux.

Then I can use <Leader>so to open my previous development session.

After loading a previous development session.

When I want to run commands, I can switch to the next tmux window.

After switching to my tmux terminal window.

Unfortunately, tmuxp has a bug where the layout is not set up correctly, so the split is too far to the left. I have a tmux key mapping to move it.

Update, 28 Jan 2021: I found out that if you switch the layouts from main-vertical to even-horizontal, the bug goes away! I have already made the switch everywhere, and I suggest you do too.

After moving the tmux split to the right.

In this case, I need to build my bc, so I configure it.

After configuring my bc.

And build it.

After building my bc.

Then I can move to my debugging window.

After moving to the debugging window.

Same tmuxp bug again; let’s move that.

After moving the tmux split to the right.

Thank goodness we only have to do that once per tmux window per session…

In this case, I want to debug my bc’s addition, so let’s start Neovim’s terminal debugger with :TermdebugCommand bin/bc -lq tests/bc/add.txt, which I can do by hand the first time, or by <Leader>. after that.

After running Neovim's termdebug.

This opens up two extra panes in Neovim, one for the program output (center left top) and one for controlling gdb (center left bottom).

After that, I need to start gdb, which I can do with <C-e>.

After starting gdb.

It would be at this point that I would load previous breakpoints with lb in the gdb window or by pressing <F9>. In this case, however, I don’t have any breakpoints to load, so I type i to get into insert mode, then b bc_num_as to put a breakpoint at bc_num_as(), the function that does both addition and subtraction.

After creating a breakpoint.

Then I hit <Esc><F3> to go to the breakpoint.

After continuing to the breakpoint.

Then I can press <F4> to step to next line.

After stepping to the next line.

This one will skip function calls, but if I wanted to enter the called function, I could use <F5>. And to finish a function, I can press <F6>.

Then I step again.

After stepping again.

If I want to see where I am in the source across the call stack, I can press <F7> to go up the stack.

After going up one function in the call stack.

After looking around, I go back down with <F8>.

After going down one function in the call stack.

Please ignore the extra output in the gdb window; I actually went up a few more times when creating these screenshots.

If I think the variables are going to take up to much space, I can go into insert mode and type pack.

After packing variables.

If I want to go back, I can type expand.

I have a problem: I have a global variable that I want to see. gdb-dashboard lets me do that. I type dashboard expressions watch <variable_name>. In this case, it’s called vm:

After telling gdb-dashboard to watch the vm global.

Then I disable the breakpoint using its number.

After disabling the breakpoint.

Then I hit <Esc><F3> to continue again.

After continuing again.

Notice that the program output is in the center left top window. If I wanted to input something, I can press <F2>i to go to that window and enter insert mode.

Instead, all looks good, so I enter insert mode and press <C-c> to send an interrupt to gdb.

After interrupting gdb.

Then I type quit.

Getting ready to quit.

After I press <Enter>, Neovim closes the program window.

After quitting.

Then I press <C-x> (mapped to :q) to quit the gdb window, and Neovim is reset, ready for another debugging session.

After resetting Neovim.

And if I wanted to exit my development environment, I go to any terminal window and type quit.

How I Got Here

I started out as an IDE kid. I loved how easy IDE’s made it to build and debug my code.

Since I started in Java and Android, I started with Eclipse. (This was before Android Studio was a thing.) Then after struggling through university with whatever IDE was the professor’s favorite, I briefly used KDevelop before switching to Qt Creator to contribute to a Qt-based software project.

Then since I started building libraries and tools in C, I stuck with Qt Creator for a long time, about three years until about a year and a half ago.

As I grew in my skills, however, I found myself doing more and more in the terminal and struggling against the IDE. In fact, while building bc, I would keep Qt Creator on my main monitor and a terminal on my secondary.

It was only after I started a job (that let me go a few months back) that I was introduced to Neovim by a coworker.

It didn’t take me long to realize the potential of working in Neovim, but I really liked the debugging in Qt Creator. I decided to do some research.

In the end, after getting a PR merged into Neovim, and being introduced to gdb-dashboard and tmux by that same coworker, I built what I shared in this post. It took me a good week of work, but it was worth it, for myself and my employer because I was much more productive.

Conclusion

Obviously, my setup won’t be for everyone. By their nature, development environments have to be customized for each programmer, by each programmer.

However, I hope my setup has given you a few ideas that you can use to help yourself become a better, more productive programmer.