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
Being able to debug your applications is essential for 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.
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 are being produced by your application.
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 a 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 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 File
1.
For our purpose here, which is to write something to the stdout
, especially 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 have 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);
"Result: {d}\n", .{result});
std.debug.print(}
Result: 50
You could also achieve the exact same result by getting a file descriptor object to stderr
, then, creating a writer object to stderr
, then, using the print()
method of this writer object, like in the example below:
const std = @import("std");
const stderr = std.io.getStdErr().writer();
// some more lines ...
try stderr.print("Result: {d}", .{result}); _ =
Although print debugging being 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.
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 when compiling your code. Having this in mind, when you compile your program with the build-exe
command (which was described 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.
As an example, let’s use LLDB to navigate and investigate 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 have 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 navigating through the code, and checking the objects that are being generated. If you are 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 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 have entered into the context of the add_and_increment()
function.
Also notice in the example below that, I have walked two more lines in the function’s body, then, I execute 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'
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.