5  Debugging Zig applications

Being able to debug your programs is essential to any programmer who wants to do serious programming in any language. That is why, in this chapter, we are going to talk about the available strategies and tools to debug applications written in Zig.

5.1 Printing debugging

We begin with the classic and battle-tested print debugging strategy. The key advantage that debugging offers you is visibility. With print statements you can easily see what results and objects that are being generated within your functions.

That is the essence of print debugging. Is to use print expressions to see the values that are being generated by your program, and, as a result, get a much better understanding of how your program is behaving.

Many programmers often resort to the print functions in Zig, such as the stdout.print(), or, the std.debug.print(), to get a better understanding of their programs. This is an known and old strategy that is very simple and effective, and it is better known within the programming community as print debugging. In Zig, you can either print information to the stdout or stderr streams of your system.

Let’s begin with stdout. First, you need to get access to the stdout, by calling the getStdOut() method, from the Zig standard library. This method returns a file descriptor object, and, through this object you can read/write to the stdout. I recommend you to check out all methods available in this object, by checking the page in the Zig Standard Library Official Reference for the type File1.

For our purpose here, which is to write something to the stdout, specially to debug our program, I recommend you to use the writer() method, which gives your a writer object. This writer object offers some helper methods to write stuff into the file descriptor object that represents the stdout stream. In special, the print() method.

The print() method from this writer object is a “print formatter” type of a function. In other words, this method works exactly like the printf() function from C, or, like println!() from Rust. In the first argument of the function, you specify a template string, and, in the second argument, you provide a list of values (or objects) that you want to insert into your template message.

Ideally, the template string in the first argument should contain some format specifier. Each format specifier is matched to a value (or object) that you listed in the second argument. So, if you provided 5 different objects in the second argument, then, the template string should contain 5 format specifiers, one for each object provided.

Each format specifier is represented by a single letter, and you provide this format specifier inside a pair of curly braces. So, if you want to format your object using the string specifier (s), then, you can insert the text {s} in your template string. Here is a quick list of the most used format specifiers:

  • d: for printing integers and floating-point numbers.
  • c: for printing characters.
  • s: for printing strings.
  • p: for printing memory addresses.
  • x: for printing hexadecimal values.
  • any: use any compatible format specifier (i.e. it automatically selects a format specifier for you).

The code example below, gives you an example of use of this print() method with the d format specifier.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
fn add(x: u8, y: u8) u8 {
    return x + y;
}

pub fn main() !void {
    const result = add(34, 16);
    _ = try stdout.print("Result: {d}", .{result});
}
Result: 50

Is important to emphasize that, the stdout.print() method, as you would expect, print your template string into the stdout stream of your system. However, you can also print your template string into the stderr stream if your prefer. All you need to do, is to replace the stdout.print() call with the function std.debug.print(). Like this:

const std = @import("std");
fn add(x: u8, y: u8) u8 {
    return x + y;
}

pub fn main() !void {
    const result = add(34, 16);
    std.debug.print("Result: {d}\n", .{result});
}
Result: 50

5.2 Debugging through debuggers

Although print debugging is a valid and very useful strategy, most programmers prefer to use a debugger to debug their programs. Since Zig is a low-level language, you can use either GDB (GNU Debugger), or LLDB (LLVM Project Debugger) as your debugger.

Both debuggers can work with Zig code, and it’s a matter of taste here. You choose the debugger of your preference, and you work with it. In this book, I will use LLDB as my debugger on the examples.

5.2.1 Compile your source code in Debug mode

In order to debug your program through a debugger, you must compile your source code in Debug mode. Because when you compile your source code in other modes (such as Release), the compiler usually strips out some essential information that is used by the debugger to read and track your program, like PDB (Program Database) files.

By compiling your source code in Debug mode, you ensure that the debugger will find the necessary information in your program to debug it. By default, the compiler uses the Debug mode. Having this in mind, when you compile your program with the build-exe command (that we exposed at Section 1.2.4), if you don’t specify an explicit mode through the -O command-line 2 argument, then, the compiler will compile your code in Debug mode.

5.2.2 Let’s debug a program

As an example, let’s debug some Zig code, and demonstrate how can we use LLDB to navigate and check the following piece of Zig code:

const std = @import("std");
const stdout = std.io.getStdOut().writer();

fn add_and_increment(a: u8, b: u8) u8 {
    const sum = a + b;
    const incremented = sum + 1;
    return incremented;
}

pub fn main() !void {
    var n = add_and_increment(2, 3);
    n = add_and_increment(n, n);
    _ = try stdout.print("Result: {d}!\n", .{n});
}
Result: 13!

There is nothing wrong with this program. But it is a good start for us. First, we need to compile this program with the zig build-exe command. For this example, suppose that I compiled the above Zig code into a binary executable called add_program.

zig build-exe add_program.zig

Now, we can start LLDB with add_program, like this:

lldb add_program

From now on, LLDB is started, and you can know that I’m executing LLDB commands by looking at the prefix (lldb). If something is prefixed with (lldb), then you know that it is a LLDB command.

The first thing I will do, is to set a breakpoint at the main() function, by executing b main. After that, I just start the execution of the program with run. You can see in the output below, that the execution stopped at the first line in the function main(), as we expected.

(lldb) b main
Breakpoint 1: where = debugging`debug1.main + 22
    at debug1.zig:11:30, address = 0x00000000010341a6
(lldb) run
Process 8654 launched: 'add_program' (x86_64)
Process 8654 stopped
* thread #1, name = 'add_program',
    stop reason = breakpoint 1.1 frame #0: 0x10341a6
    add_program`debug1.main at add_program.zig:11:30
   8    }
   9    
   10   pub fn main() !void {
-> 11       var n = add_and_increment(2, 3);
   12       n = add_and_increment(n, n);
   13       try stdout.print("Result: {d}!\n", .{n});
   14   }

I can start to navigate through the code, and investigating the variables that are being generated. If you not familiar with the commands available in LLDB, I recommend you to read the official documentation of the project3. You can also look for cheat sheets. Which quickly describes all commands available for you, and, as a result, are also good resources for you4.

Currently, we are in the first line at the main() function. In this line, we create the n object, by executing the add_and_increment() function. To execute the current line of code, and go to the next line, we can run the n LLDB command. Let’s execute this command.

After we executed this line, we can also look at the value stored inside this n object by using the p LLDB command. The syntax for this command is p <name-of-object>.

If we take a look at the value stored in the n object (p n), notice that it stores the hexadecimal value 0x06, which is the number 6 in decimal. We can also see that, this value have a type unsigned char, which is an unsigned 8-bit integer. We have talked already about this at Section 1.8, that u8 integers in Zig are equivalent to the C data type unsigned char.

(lldb) n
Process 4798 stopped
* thread #1, name = 'debugging',
    stop reason = step over frame #0: 0x10341ae
    debugging`debug1.main at debug1.zig:12:26
   9    
   10   pub fn main() !void {
   11       var n = add_and_increment(2, 3);
-> 12       n = add_and_increment(n, n);
   13       try stdout.print("Result: {d}!\n", .{n});
   14   }
(lldb) p n
(unsigned char) $1 = '\x06'

Now, on the next line of code, we are executing the add_and_increment() function once again. Why not step inside this function? Shall we? We can do that, by executing the s LLDB command. Notice in the example below that, after executing this command, we entered inside the context of the add_and_increment() function.

Also notice in the example below that, I walked two more lines in the functions body, then, I executed the frame variable LLDB command, to see at once, the value stored in each of the variables that were created inside the current scope.

You can see in the output below that, the object sum stores the value \f, which represents the form feed character. This character in the ASCII table, corresponds to the hexadecimal value 0x0C, or, in decimal, the number 12. So, this means that the result of the expression a + b executed at line 5, resulted in the number 12.

(lldb) s
Process 4798 stopped
* thread #1, name = 'debugging',
    stop reason = step in frame #0: 0x10342de
    debugging`debug1.add_and_increment(a='\x02', b='\x03')
    at debug1.zig:4:39
-> 4    fn add_and_increment(a: u8, b: u8) u8 {
   5        const sum = a + b;
   6        const incremented = sum + 1;
   7        return incremented;
(lldb) n
(lldb) n
(lldb) frame variable
(unsigned char) a = '\x06'
(unsigned char) b = '\x06'
(unsigned char) sum = '\f'
(unsigned char) incremented = '\x06'

5.3 How to investigate the data type of your objects

Since Zig is a strongly-typed language, the data types associated with your objects are very important for your program. So, debugging the data types associated with your objects might be important to understand bugs and errors in your program.

When you walk through your program with a debugger, you can inspect the types of your objects by simply printing them to the console, with the LLDB p command. But you also have alternatives embedded in the language itself to access the data types of your objects.

In Zig, you can retrieve the data type of an object, by using the built-in function @TypeOf(). Just apply this function over the object, and you get access to the data type of the object.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const expect = std.testing.expect;

pub fn main() !void {
    const number: i32 = 5;
    try expect(@TypeOf(number) == i32);
    try stdout.print("{any}\n", .{@TypeOf(number)});
}
i32

This function is similar to the type() built-in function from Python, or, the typeof operator in Javascript.


  1. https://ziglang.org/documentation/master/std/#std.fs.File.↩︎

  2. See https://ziglang.org/documentation/master/#Debug.↩︎

  3. https://lldb.llvm.org/↩︎

  4. https://gist.github.com/ryanchang/a2f738f0c3cc6fbd71fa.↩︎