Skip to content

Zig Undefined Behavior

Undefined behavior is an important concept in systems programming. Zig uses various mechanisms to detect and prevent undefined behavior. This chapter will detail these concepts and best practices.

What is Undefined Behavior?

Undefined Behavior (UB) refers to operations performed by the program that are not defined in the language specification, with unpredictable results:

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Undefined behavior examples\n");

    // ❌ These operations may lead to undefined behavior:

    // 1. Using uninitialized variables
    // var uninitialized: i32 = undefined;
    // std.debug.print("Uninitialized value: {}\n", .{uninitialized}); // UB!

    // 2. Array out-of-bounds access
    // const array = [_]i32{1, 2, 3};
    // std.debug.print("Out-of-bounds access: {}\n", .{array[10]}); // UB!

    // 3. Integer overflow
    // const max_int: i8 = 127;
    // const overflow = max_int + 1; // UB in release mode!

    std.debug.print("These examples are commented out because they cause undefined behavior\n");
}

Zig's Safety Mechanisms

Runtime Safety Checks

Zig provides runtime safety checks in debug mode:

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Runtime safety check examples\n");

    // Array bounds checking
    const array = [_]i32{ 1, 2, 3, 4, 5 };

    for (0..array.len) |i| {
        std.debug.print("array[{}] = {}\n", .{ i, array[i] });
    }

    // In debug mode, the following code will trigger a panic
    // std.debug.print("Out-of-bounds access: {}\n", .{array[10]});

    // Integer overflow checking
    var counter: u8 = 250;
    for (0..10) |i| {
        std.debug.print("Counter {}: {}\n", .{ i, counter });

        // Check for overflow in debug mode
        if (counter > 255 - 1) {
            std.debug.print("About to overflow, stopping increment\n", .{});
            break;
        }
        counter += 1;
    }
}

Compile-Time Checks

Zig can detect many potential undefined behaviors at compile time:

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Compile-time check examples\n");

    // ✅ Compile-time known safe operations
    const safe_array = [_]i32{ 1, 2, 3, 4, 5 };
    const safe_index = 2;
    std.debug.print("Safe access: array[{}] = {}\n", .{ safe_index, safe_array[safe_index] });

    // ❌ The following code will cause a compilation error:
    // const unsafe_index = 10;
    // std.debug.print("Unsafe access: {}\n", .{safe_array[unsafe_index]});

    // ✅ Compile-time evaluation is safe
    const compile_time_result = comptime blk: {
        var sum: i32 = 0;
        for (safe_array) |value| {
            sum += value;
        }
        break :blk sum;
    };

    std.debug.print("Compile-time evaluated sum: {}\n", .{compile_time_result});
}

Common Undefined Behaviors

1. Uninitialized Variables

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Uninitialized variable examples\n");

    // ❌ Error: Using uninitialized variables
    // var bad_var: i32 = undefined;
    // std.debug.print("Uninitialized value: {}\n", .{bad_var}); // UB!

    // ✅ Correct: Explicit initialization
    var good_var: i32 = 0;
    std.debug.print("Initialized value: {}\n", .{good_var});

    // ✅ Correct: Conditional initialization
    var conditional_var: i32 = undefined;
    const should_initialize = true;

    if (should_initialize) {
        conditional_var = 42;
    } else {
        conditional_var = 0;
    }

    std.debug.print("Conditionally initialized value: {}\n", .{conditional_var});

    // ✅ Correct: Using optional types
    var maybe_value: ?i32 = null;
    maybe_value = 100;

    if (maybe_value) |value| {
        std.debug.print("Optional value: {}\n", .{value});
    } else {
        std.debug.print("No value\n", .{});
    }
}

2. Array Out of Bounds

zig
const std = @import("std");

fn safeArrayAccess(array: []const i32, index: usize) ?i32 {
    if (index >= array.len) {
        return null;
    }
    return array[index];
}

pub fn main() void {
    std.debug.print("Array out-of-bounds protection examples\n");

    const numbers = [_]i32{ 10, 20, 30, 40, 50 };

    // ✅ Safe array access
    for (0..numbers.len) |i| {
        std.debug.print("numbers[{}] = {}\n", .{ i, numbers[i] });
    }

    // ✅ Using safe access function
    const test_indices = [_]usize{ 2, 5, 10 };

    for (test_indices) |index| {
        if (safeArrayAccess(&numbers, index)) |value| {
            std.debug.print("Safe access numbers[{}] = {}\n", .{ index, value });
        } else {
            std.debug.print("Index {} out of range\n", .{index});
        }
    }

    // ✅ Using slice bounds checking
    const slice = numbers[1..4];
    std.debug.print("Slice contents: ");
    for (slice) |value| {
        std.debug.print("{} ", .{value});
    }
    std.debug.print("\n");
}

3. Integer Overflow

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Integer overflow handling examples\n");

    // ✅ Using overflow-checked arithmetic operations
    const a: u8 = 200;
    const b: u8 = 100;

    // Check for addition overflow
    if (@addWithOverflow(a, b)[1] != 0) {
        std.debug.print("Addition overflow: {} + {} will overflow\n", .{ a, b });
    } else {
        const sum = @addWithOverflow(a, b)[0];
        std.debug.print("Safe addition: {} + {} = {}\n", .{ a, b, sum });
    }

    // Using saturated arithmetic
    const saturated_add = std.math.add(u8, a, b) catch std.math.maxInt(u8);
    std.debug.print("Saturated addition: {} + {} = {} (max value: {})\n",
                    .{ a, b, saturated_add, std.math.maxInt(u8) });

    // ✅ Using larger types to avoid overflow
    const large_a: u16 = a;
    const large_b: u16 = b;
    const large_sum = large_a + large_b;
    std.debug.print("Using larger type: {} + {} = {}\n", .{ large_a, large_b, large_sum });

    // ✅ Check for multiplication overflow
    const x: u32 = 1000000;
    const y: u32 = 5000;

    if (std.math.mul(u32, x, y)) |product| {
        std.debug.print("Safe multiplication: {} * {} = {}\n", .{ x, y, product });
    } else |err| {
        std.debug.print("Multiplication overflow: {} * {} leads to {}\n", .{ x, y, err });
    }
}

4. Null Pointer Dereference

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Null pointer protection examples\n");

    // ✅ Using optional pointers
    var maybe_ptr: ?*i32 = null;
    var value: i32 = 42;

    // Check for null pointer
    if (maybe_ptr) |ptr| {
        std.debug.print("Pointer value: {}\n", .{ptr.*});
    } else {
        std.debug.print("Pointer is null\n", .{});
    }

    // Set pointer
    maybe_ptr = &value;

    if (maybe_ptr) |ptr| {
        std.debug.print("Pointer value: {}\n", .{ptr.*});
        ptr.* = 100;
        std.debug.print("Modified value: {}\n", .{value});
    }

    // ✅ Using orelse to provide default values
    const safe_value = if (maybe_ptr) |ptr| ptr.* else 0;
    std.debug.print("Safely accessed value: {}\n", .{safe_value});
}

Memory Safety

Dangling Pointers

zig
const std = @import("std");

// ❌ Dangerous: Returning pointer to local variable
// fn dangling_pointer() *i32 {
//     var local_var: i32 = 42;
//     return &local_var; // Dangling pointer!
// }

// ✅ Safe: Using allocator
fn safe_allocation(allocator: std.mem.Allocator) !*i32 {
    const ptr = try allocator.create(i32);
    ptr.* = 42;
    return ptr;
}

// ✅ Safe: Returning value instead of pointer
fn safe_value() i32 {
    const local_var: i32 = 42;
    return local_var;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    std.debug.print("Memory safety examples\n");

    // ✅ Safe memory allocation
    const safe_ptr = try safe_allocation(allocator);
    defer allocator.destroy(safe_ptr);

    std.debug.print("Safely allocated value: {}\n", .{safe_ptr.*});

    // ✅ Returning value instead of pointer
    const safe_val = safe_value();
    std.debug.print("Safely returned value: {}\n", .{safe_val});

    // ✅ Using RAII pattern
    const ManagedResource = struct {
        data: *i32,
        allocator: std.mem.Allocator,

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator, value: i32) !Self {
            const data = try allocator.create(i32);
            data.* = value;
            return Self{
                .data = data,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self) void {
            self.allocator.destroy(self.data);
        }

        pub fn getValue(self: *const Self) i32 {
            return self.data.*;
        }
    };

    var resource = try ManagedResource.init(allocator, 123);
    defer resource.deinit();

    std.debug.print("Managed resource value: {}\n", .{resource.getValue()});
}

Buffer Overflow

zig
const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    std.debug.print("Buffer overflow protection examples\n");

    // ✅ Safe string copying
    const source = "Hello, World!";
    var buffer: [20]u8 = undefined;

    if (source.len < buffer.len) {
        @memcpy(buffer[0..source.len], source);
        buffer[source.len] = 0; // null terminator

        const copied_string = buffer[0..source.len :0];
        std.debug.print("Safe copy: {s}\n", .{copied_string});
    } else {
        std.debug.print("Source string too long to copy to buffer\n", .{});
    }

    // ✅ Using dynamic allocation
    const dynamic_buffer = try allocator.dupe(u8, source);
    defer allocator.free(dynamic_buffer);

    std.debug.print("Dynamic allocation: {s}\n", .{dynamic_buffer});

    // ✅ Using ArrayList for automatic size management
    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    try list.appendSlice(source);
    try list.appendSlice(" - Appended content");

    std.debug.print("ArrayList: {s}\n", .{list.items});
}

Debugging Undefined Behavior

Using Debug Mode

zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Debug mode check\n");

    // In debug mode, these checks are automatically enabled
    const builtin = @import("builtin");

    std.debug.print("Debug mode: {}\n", .{builtin.mode == .Debug});
    std.debug.print("Runtime safety: {}\n", .{std.debug.runtime_safety});

    // Conditional compilation of debug code
    if (std.debug.runtime_safety) {
        std.debug.print("Runtime safety checks enabled\n", .{});

        // Additional debug checks
        const array = [_]i32{ 1, 2, 3 };
        for (0..array.len) |i| {
            std.debug.assert(i < array.len);
            std.debug.print("array[{}] = {}\n", .{ i, array[i] });
        }
    }
}

Custom Assertions

zig
const std = @import("std");

fn customAssert(condition: bool, comptime message: []const u8) void {
    if (!condition) {
        std.debug.print("Assertion failed: {s}\n", .{message});
        if (std.debug.runtime_safety) {
            std.debug.panic("Program terminated", .{});
        }
    }
}

fn safeDivide(a: f64, b: f64) f64 {
    customAssert(b != 0.0, "Divisor cannot be zero");
    return a / b;
}

pub fn main() void {
    std.debug.print("Custom assertion examples\n");

    const result1 = safeDivide(10.0, 2.0);
    std.debug.print("10.0 / 2.0 = {d:.2}\n", .{result1});

    // This will trigger an assertion
    // const result2 = safeDivide(10.0, 0.0);

    std.debug.print("Assertion check completed\n");
}

Best Practices

1. Defensive Programming

zig
const std = @import("std");

const SafeArray = struct {
    data: []i32,
    allocator: std.mem.Allocator,

    const Self = @This();

    pub fn init(allocator: std.mem.Allocator, size: usize) !Self {
        if (size == 0) return error.InvalidSize;

        const data = try allocator.alloc(i32, size);
        @memset(data, 0); // Initialize to zero

        return Self{
            .data = data,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Self) void {
        self.allocator.free(self.data);
    }

    pub fn get(self: *const Self, index: usize) ?i32 {
        if (index >= self.data.len) return null;
        return self.data[index];
    }

    pub fn set(self: *Self, index: usize, value: i32) bool {
        if (index >= self.data.len) return false;
        self.data[index] = value;
        return true;
    }

    pub fn size(self: *const Self) usize {
        return self.data.len;
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    std.debug.print("Defensive programming examples\n");

    var safe_array = try SafeArray.init(allocator, 5);
    defer safe_array.deinit();

    // Safe set values
    for (0..safe_array.size()) |i| {
        const success = safe_array.set(i, @intCast(i * 10));
        std.debug.print("Setting array[{}] = {}: {}\n", .{ i, i * 10, success });
    }

    // Safe get values
    for (0..safe_array.size() + 2) |i| {
        if (safe_array.get(i)) |value| {
            std.debug.print("array[{}] = {}\n", .{ i, value });
        } else {
            std.debug.print("array[{}] = Index out of range\n", .{i});
        }
    }
}

2. Error Handling

zig
const std = @import("std");

const ValidationError = error{
    NullPointer,
    InvalidRange,
    BufferTooSmall,
};

fn validateAndProcess(data: ?[]const u8, min_size: usize, buffer: []u8) ValidationError!usize {
    // Check for null pointer
    const valid_data = data orelse return ValidationError.NullPointer;

    // Check size range
    if (valid_data.len < min_size) {
        return ValidationError.InvalidRange;
    }

    // Check buffer size
    if (buffer.len < valid_data.len) {
        return ValidationError.BufferTooSmall;
    }

    // Safe copy
    @memcpy(buffer[0..valid_data.len], valid_data);

    return valid_data.len;
}

pub fn main() void {
    std.debug.print("Error handling examples\n");

    var buffer: [100]u8 = undefined;

    const test_cases = [_]struct {
        data: ?[]const u8,
        min_size: usize,
        description: []const u8,
    }{
        .{ .data = "Hello, World!", .min_size = 5, .description = "Normal case" },
        .{ .data = null, .min_size = 5, .description = "Null pointer" },
        .{ .data = "Hi", .min_size = 5, .description = "Data too small" },
        .{ .data = "A" ** 150, .min_size = 5, .description = "Buffer too small" },
    };

    for (test_cases) |test_case| {
        std.debug.print("Test: {s}\n", .{test_case.description});

        if (validateAndProcess(test_case.data, test_case.min_size, &buffer)) |size| {
            const processed_data = buffer[0..size];
            std.debug.print("  Successfully processed {} bytes\n", .{size});
            if (size <= 20) {
                std.debug.print("  Content: {s}\n", .{processed_data});
            }
        } else |err| {
            std.debug.print("  Error: {}\n", .{err});
        }
    }
}

Summary

This chapter detailed the concept of undefined behavior and protective measures:

  • ✅ Definition and dangers of undefined behavior
  • ✅ Zig's safety mechanisms and checks
  • ✅ Identifying and avoiding common undefined behaviors
  • ✅ Memory safety and pointer management
  • ✅ Debugging and detection tools
  • ✅ Defensive programming best practices

Understanding and avoiding undefined behavior is key to writing safe, reliable systems software. Zig helps developers build safer programs through compile-time checks, runtime safety checks, and explicit error handling mechanisms.

In the next chapter, we will learn about Zig's engineering and package management.

Content is for learning and research only.