Zig, the small language

If you look at the HackerNews headlines and comments - you may notice that every now and then people praise Zig, a relatively young programming language for low-level programming. Maybe not as low-level as assembler, but definitely as low-level as C or C++.

I’m usually very skeptical about new languages, so naturally I wanted to give Zig a try. But after playing around and building a few toy projects with it - I shall say, I really enjoyed coding in Zig. It is very similar to the good side of C, when the language feels simple and small, resulting programs remain tiny and fast, and coding feels like “coding” and not like “engineering” or “architecting”. Zig remains fun.

Zig is small

One of the reasons while Zig feels like fun, is because the language itself is small. One can explore the complete syntax and basic language constructs in less than an hour. For example, here’s my attempt to introduce Zig with a few lines of code:

// Single-line comments start with "//", documentation comments start with "///"

const x: i32 = 42; // immutable int32 value that can not be changed
var y: u32 = 5;    // mutable unsigned int32 variable
var z = y; // type can be omitted if it can be inferred from the value

// There is no "int" type, all integers have fixed width.
// Same about floats, there are f16, f32, f64 and f128.
// For indices, "intptr_t" or "size_t" types use "isize" or "usize".

// All function parameters are immutable as if they are passed-by-value.
fn add_two_ints(a: i32, b: i32) i32 {
	if (a == 0) { // if statement looks like C
		return b;
	}
	return a+b;
}

// Arrays have fixed length, here numbers.len == 5
const numbers = [_]i32{ 0, 1, 3, 5, 7 };
// String literals are arrays of []u8
const hello = "hello";
// Arrays can be initialised with repeating values using ** operator
const ten_zero_bytes = [_]u8{0} ** 10;
// Arrays may contain a sentinel value at the end, here array.len == 4 and array[4] == 0.
const array = [_:0]u8 {1, 2, 3, 4};
// Slices are pointers to array data with associated length. The difference between
// arrays and slices is that array's length is known at compile time, while slice
// length is only known at runtime. Like arrays, slices also perform bounds checking.
const full_slice = numbers[0..]; // points at &numbers[0] and has length of 5
const short_slice = numbers[1..3]; // points at &numbers[1] and has length of 2

fn count_nonzero(a: []const i32) i32 {
	var count: i32 = 0;
	for (items) |value| { // "for" works only on arrays and slices, use "while" for generic loops.
		if (value == 0) {
			continue;
		}
		count += 1; // there is no increment operator, but there are shortcuts for +=, *=, >>= etc.
	}
}

pub fn main() void { // main() is a special entry point to your program
	var eight = add_two_ints(3, 5);
	var nonzeros = count_nonzero(full_slice);
}

Naturally, it has structs, error types, defer, enums and even means for meta-programming, and all of this can be discovered after skimming through https://ziglearn.org.

I personally enjoy the way how Zig modules and macros are implemented. It feels very elegant to see existing language features being reused to achieve more functionality.

Zig programs are small

Zig runtime is very bare-bones: no GC, no utilities for HTTP or JSON, even no way to concatenate strings! But this also implies that most minimal Zig programs can remain small.

Let’s try writing a “Hello world”:

const std = @import("std");
pub fn main() void {
  std.io.getStdOut().writeAll("Hello, World!\n") catch unreachable;
}

If we just compile it with a regular Zig compiler for amd64 we will result in a binary of ~2KB. By modern terms this is even smaller than a corresponding C binary.

This can further be improved by using Linux system calls directly and enabling LDD as a linker:

const std = @import("std");
const linux = std.os.linux;

pub export fn _start() callconv(.Naked) noreturn {
    _ = linux.write(1, "hello", 5);
    linux.exit(0);
}

$ zig build-exe hello.zig --strip -OReleaseSmall --name hello -target x86_64-linux -flto -fLLD

This gives a binary of ~700 bytes, which can additionally be stripped down using sstrip or custom linker scripts to ~200 bytes.

Zig for Arduino?

Another platform that allows testing low-level languages is Arduino. Old ATMega MCUs barely have 2KB of RAM and only a few KB of ROM for code. Can Zig blink an LED without too many hacks?

Actually, yes:

const avr = struct {
    pub const ddrb = @intToPtr(*volatile u8, 0x24);
    pub const portb = @intToPtr(*volatile u8, 0x25);
};

const led_pin: u8 = 5;
const led_bit: u8 = 1 << led_pin;
const loop_ms = 0x0a52;

fn flipLed() void {
    avr.portb.* ^= led_bit;
}

fn delay(ms: u8) void {
    var count: u8 = 0;
    while (count < ms) : (count += 1) {
        var loop: u16 = 0;
        while (loop < loop_ms) : (loop += 1) {
            asm volatile ("" ::: "memory");
        }
    }
}

export fn main() noreturn {
    avr.ddrb.* = led_bit;
    avr.portb.* = led_bit;
    while (true) {
        flipLed();
        delay(250);
    }
}

This can be cross-compiled for the avr-freestanding-none target and it produces the following assembly code:

main:
	ldi	r24, 32
	out	4, r24
	out	5, r24
	ldi	r18, 0
	ldi	r19, 0
	ldi	r25, 10
.LBB0_1:
	in	r20, 5
	eor	r20, r24
	out	5, r20
	ldi	r20, 0
.LBB0_2:
	cpi	r20, -6
	breq	.LBB0_1
	movw	r30, r18
.LBB0_4:
	cpi	r30, 82
	cpc	r31, r25
	brsh	.LBB0_6
	adiw	r30, 1
	rjmp	.LBB0_4
.LBB0_6:
	inc	r20
	rjmp	.LBB0_2

20 assembly instructions that toggle the LED (in+eor+out) and do the countdown loop in between the blinks. Just like the C compiler would do.

Zig for Demoscene?

So far Zig has been a decent competitor to C. There is yet another niche, where no-nonsense languages like C have been shining for decades - demoscene. Nowhere close to a traditional industrial programming, demoscene is an art, where some visually or audibly appealing applications are written to be executed in a limited environment. Most often a binary size is a constraint, and people fit amazing artworks into anything from 512 to 4096 bytes.

Of course, it’s hard to build a cross-platform binary to be that small, so let’s assume that our platform is a modern Linux distro with SDL2. I never participated in demo scene myself, so don’t expect much. My goal here is to get SDL2 + Zig running with something visual in less than 4KB.

const sdl = @cImport({
    @cInclude("SDL2/SDL.h");
});
const std = @import("std");

pub fn main() void {
    _ = sdl.SDL_Init(sdl.SDL_INIT_VIDEO);
    defer sdl.SDL_Quit();
    const window = sdl.SDL_CreateWindow("Hello", sdl.SDL_WINDOWPOS_CENTERED, sdl.SDL_WINDOWPOS_CENTERED, 166, 166, 0);
    defer sdl.SDL_DestroyWindow(window);
    const surface = sdl.SDL_GetWindowSurface(window);

    var quit = false;
    while (!quit) {
        var event: sdl.SDL_Event = undefined;
        while (sdl.SDL_PollEvent(&event) != 0) {
            switch (event.@"type") {
                sdl.SDL_QUIT => {
                    quit = true;
                },
                else => {},
            }
        }
				// Draw a white cross on red background
        _ = sdl.SDL_FillRect(surface, 0, 0xff0000);
        const v = sdl.SDL_Rect{ .x = 33, .y = 66, .w = 100, .h = 34 };
        _ = sdl.SDL_FillRect(surface, &v, 0xffffff);
        const h = sdl.SDL_Rect{ .x = 66, .y = 33, .w = 34, .h = 100 };
        _ = sdl.SDL_FillRect(surface, &h, 0xffffff);
        _ = sdl.SDL_UpdateWindowSurface(window);
    }
}
// zig build-exe main.zig -OReleaseSmall --strip $(shell pkg-config --libs --cflags sdl2) -lc

Such an executable, dynamically linked, is 3700 bytes. Yeah, there’s not much space left for the actual art, but then again - further optimisations and linker hacks should be able to strip this down a bit. What impressed me the most is how easy it was to deal with SDL2 (a C library) from Zig without having to download any wrappers or writing any of the glue code.

Zig for everything else

Being small is probably the best thing about Zig. It takes a few days to become familiar and productive with the language. Zig has a wonderful integration with C, including C headers and calling C functions from Zig via FFI just works. This already allows using Zig where C would have been used instead and that’s quite a large area.

I look forward to Zig becoming more stable, hopefully having a self-hosted compiler and a package manager. It’s a very appealing language to the modern low-end (and not only) applications, that deserves a bright future.

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Jun 01, 2022

See also: A "Better C" Benchmark and more.