Learn Zig Series (#75) - Reading Kernel State from /proc and /sys

avatar

Learn Zig Series (#75) - Reading Kernel State from /proc and /sys

zig.png

What will I learn

  • How the Linux /proc filesystem exposes process and kernel information as plain text files;
  • How to read /proc/self/maps to inspect your own process memory layout from Zig;
  • How to query /proc/[pid]/status, stat, and cmdline for detailed process information;
  • How to parse /proc/net/tcp and /proc/net/udp for network connection state;
  • How /sys exposes hardware, driver, and device information in a structured tree;
  • How to read /proc/cpuinfo and /proc/meminfo for system-level statistics;
  • How to parse the different formats found across /proc and /sys: colon-separated, space-separated, hex-encoded;
  • How to build a practical system info tool that reports CPU, memory, network, and process data.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#75) - Reading Kernel State from /proc and /sys

Solutions to Episode 74 Exercises

Exercise 1: Memory watchpoint tool using ptrace

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

fn ptrace(req: u32, pid: i32, addr: usize, data: usize) isize {
    return @bitCast(linux.syscall4(.ptrace, req,
        @as(usize, @bitCast(@as(isize, pid))), addr, data));
}

const UserRegs = extern struct {
    r15: u64, r14: u64, r13: u64, r12: u64, rbp: u64, rbx: u64,
    r11: u64, r10: u64, r9: u64, r8: u64, rax: u64, rcx: u64,
    rdx: u64, rsi: u64, rdi: u64, orig_rax: u64, rip: u64, cs: u64,
    eflags: u64, rsp: u64, ss: u64, fs_base: u64, gs_base: u64,
    ds: u64, es: u64, fs: u64, gs: u64,
};

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    var args = std.process.args();
    _ = args.next();
    const pid_str = args.next() orelse { try stdout.print("Usage: watchpoint <pid> <hex-addr>\n", .{}); return; };
    const addr_str = args.next() orelse { try stdout.print("Missing address\n", .{}); return; };
    const pid = std.fmt.parseInt(i32, pid_str, 10) catch { try stdout.print("Invalid PID\n", .{}); return; };
    const addr = std.fmt.parseInt(usize, addr_str, 16) catch { try stdout.print("Invalid addr\n", .{}); return; };

    if (ptrace(16, pid, 0, 0) < 0) { try stdout.print("Attach failed\n", .{}); return; } // ATTACH
    _ = posix.waitpid(pid, 0);
    var prev: usize = @bitCast(ptrace(2, pid, addr, 0)); // PEEKDATA
    try stdout.print("Watching 0x{x}, initial=0x{x}\n", .{ addr, prev });

    var changes: u32 = 0;
    while (changes < 5) {
        _ = ptrace(7, pid, 0, 0); // CONT
        std.time.sleep(50_000_000); // 50ms poll
        _ = linux.syscall2(.kill, @as(usize, @bitCast(@as(isize, pid))), 19);
        const wr = posix.waitpid(pid, 0);
        if (wr.status.exit_status() != null) break;
        const cur: usize = @bitCast(ptrace(2, pid, addr, 0));
        if (cur != prev) {
            var regs: UserRegs = undefined;
            _ = ptrace(12, pid, 0, @intFromPtr(&regs));
            changes += 1;
            try stdout.print("[{d}] 0x{x} -> 0x{x} (RIP=0x{x})\n", .{ changes, prev, cur, regs.rip });
            prev = cur;
        }
    }
    _ = ptrace(17, pid, 0, 0); // DETACH
}

The polling approach (stop, peek, continue, repeat every 50ms) is simple but effective. Hardware watchpoints via DR0-DR3 debug registers would be more efficient but add significantly more complexity.

Exercise 2: File tracer with openat flag decoding and per-file summary

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

fn pt(req: u32, pid: i32, addr: usize, data: usize) isize {
    return @bitCast(linux.syscall4(.ptrace, req,
        @as(usize, @bitCast(@as(isize, pid))), addr, data));
}
fn readStr(pid: i32, addr: usize, buf: []u8) []u8 {
    if (addr == 0) return buf[0..0];
    var off: usize = 0;
    while (off < buf.len - 1) {
        const bytes: [8]u8 = @bitCast(@as(usize, @bitCast(pt(2, pid, addr + off, 0))));
        for (bytes) |b| { if (b == 0 or off >= buf.len - 1) return buf[0..off]; buf[off] = b; off += 1; }
    }
    return buf[0..off];
}
const UserRegs = extern struct {
    r15: u64, r14: u64, r13: u64, r12: u64, rbp: u64, rbx: u64,
    r11: u64, r10: u64, r9: u64, r8: u64, rax: u64, rcx: u64,
    rdx: u64, rsi: u64, rdi: u64, orig_rax: u64, rip: u64, cs: u64,
    eflags: u64, rsp: u64, ss: u64, fs_base: u64, gs_base: u64,
    ds: u64, es: u64, fs: u64, gs: u64,
};
const FileStat = struct { path: [256]u8 = undefined, path_len: usize = 0,
    flags: u64 = 0, reads: u32 = 0, writes: u32 = 0, read_bytes: u64 = 0, write_bytes: u64 = 0 };

pub fn main() !void {
    const stderr = std.io.getStdErr().writer();
    var args = std.process.args(); _ = args.next();
    const target = args.next() orelse { try stderr.print("Usage: ftrace <cmd>\n", .{}); return; };
    const pid = try posix.fork();
    if (pid == 0) {
        _ = pt(0, 0, 0, 0); _ = linux.syscall2(.kill, linux.syscall0(.getpid), 19);
        const argv = [_]?[*:0]const u8{ @ptrCast(target.ptr), null };
        _ = linux.syscall3(.execve, @intFromPtr(argv[0].?), @intFromPtr(&argv), @intFromPtr(&[_]?[*:0]const u8{null}));
        posix.exit(127);
    }
    _ = posix.waitpid(pid, 0);
    var stats: [256]FileStat = [_]FileStat{.{}} ** 256;
    var entering = true; var entry_regs: UserRegs = undefined; var pbuf: [256]u8 = undefined;
    while (true) {
        _ = pt(24, pid, 0, 0);
        const wr = posix.waitpid(pid, 0);
        if (wr.status.exit_status() != null or wr.status.signal() != null) break;
        var regs: UserRegs = undefined;
        _ = pt(12, pid, 0, @intFromPtr(&regs));
        if (entering) { entry_regs = regs; } else {
            const ret: isize = @bitCast(regs.rax); const nr = entry_regs.orig_rax;
            if (nr == 257 and ret >= 0 and ret < 256) { const fd: usize = @intCast(ret);
                const p = readStr(pid, entry_regs.rsi, &pbuf);
                @memcpy(stats[fd].path[0..p.len], p); stats[fd].path_len = p.len; stats[fd].flags = entry_regs.rdx;
            } else if (nr == 0 and ret > 0) { const fd: usize = @intCast(@as(isize, @bitCast(entry_regs.rdi)));
                if (fd < 256 and stats[fd].path_len > 0) { stats[fd].reads += 1; stats[fd].read_bytes += @intCast(ret); }
            } else if (nr == 1 and ret > 0) { const fd: usize = @intCast(@as(isize, @bitCast(entry_regs.rdi)));
                if (fd < 256 and stats[fd].path_len > 0) { stats[fd].writes += 1; stats[fd].write_bytes += @intCast(ret); }
            }
        }
        entering = !entering;
    }
    try stderr.print("\n=== Per-File Summary ===\n", .{});
    for (stats, 0..) |s, fd| {
        if (s.reads > 0 or s.writes > 0) {
            const acc = @as(u32, @intCast(s.flags & 3));
            const mode = ([_][]const u8{ "RDONLY", "WRONLY", "RDWR", "???" })[acc];
            try stderr.print("fd {d}: \"{s}\" ({s}) R:{d}({d}B) W:{d}({d}B)\n", .{
                fd, s.path[0..s.path_len], mode, s.reads, s.read_bytes, s.writes, s.write_bytes });
        }
    }
}

The per-file summary gives you the I/O profile of any program in one glance -- which files were opened, with what mode, how many reads and writes, total bytes in each direction.

Exercise 3: Syscall argument modifier redirecting file access

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

fn pt(req: u32, pid: i32, addr: usize, data: usize) isize {
    return @bitCast(linux.syscall4(.ptrace, req,
        @as(usize, @bitCast(@as(isize, pid))), addr, data));
}
fn readStr(pid: i32, addr: usize, buf: []u8) []u8 {
    if (addr == 0) return buf[0..0];
    var off: usize = 0;
    while (off < buf.len - 1) {
        const bytes: [8]u8 = @bitCast(@as(usize, @bitCast(pt(2, pid, addr + off, 0))));
        for (bytes) |b| { if (b == 0 or off >= buf.len - 1) return buf[0..off]; buf[off] = b; off += 1; }
    }
    return buf[0..off];
}
fn writeStr(pid: i32, addr: usize, s: []const u8) void {
    var off: usize = 0;
    while (off <= s.len) {
        var w: [8]u8 = @bitCast(@as(usize, @bitCast(pt(2, pid, addr + off, 0))));
        var i: usize = 0;
        while (i < 8 and off + i <= s.len) : (i += 1) w[i] = if (off + i < s.len) s[off + i] else 0;
        _ = pt(5, pid, addr + off, @as(usize, @bitCast(w)));
        off += 8;
    }
}
const UserRegs = extern struct {
    r15: u64, r14: u64, r13: u64, r12: u64, rbp: u64, rbx: u64,
    r11: u64, r10: u64, r9: u64, r8: u64, rax: u64, rcx: u64,
    rdx: u64, rsi: u64, rdi: u64, orig_rax: u64, rip: u64, cs: u64,
    eflags: u64, rsp: u64, ss: u64, fs_base: u64, gs_base: u64,
    ds: u64, es: u64, fs: u64, gs: u64,
};

pub fn main() !void {
    const stderr = std.io.getStdErr().writer();
    const from = "/etc/hostname"; const to = "/etc/os-release";
    const pid = try posix.fork();
    if (pid == 0) {
        _ = pt(0, 0, 0, 0); _ = linux.syscall2(.kill, linux.syscall0(.getpid), 19);
        const argv = [_]?[*:0]const u8{ @ptrCast("/bin/cat"), @ptrCast(from.ptr), null };
        _ = linux.syscall3(.execve, @intFromPtr(argv[0].?), @intFromPtr(&argv), @intFromPtr(&[_]?[*:0]const u8{null}));
        posix.exit(127);
    }
    _ = posix.waitpid(pid, 0);
    try stderr.print("Redirecting {s} -> {s}\n", .{ from, to });
    var entering = true; var pbuf: [256]u8 = undefined;
    while (true) {
        _ = pt(24, pid, 0, 0);
        const wr = posix.waitpid(pid, 0);
        if (wr.status.exit_status() != null or wr.status.signal() != null) break;
        var regs: UserRegs = undefined;
        _ = pt(12, pid, 0, @intFromPtr(&regs));
        if (entering and regs.orig_rax == 257) {
            const path = readStr(pid, regs.rsi, &pbuf);
            if (std.mem.eql(u8, path, from)) {
                const new_addr = regs.rsp - 256; // write below RSP (safe zone)
                writeStr(pid, new_addr, to);
                regs.rsi = new_addr;
                _ = pt(13, pid, 0, @intFromPtr(&regs)); // SETREGS
            }
        }
        entering = !entering;
    }
}

Writing the replacement path into unused stack space below RSP, then pointing RSI to it before the kernel processes the openat syscall -- the target program reads a completely different file and has no idea.


Last episode we built ptrace-based tools to observe and control other processes from the outside. But there's another way to inspect process and kernel state that doesn't require any special privileges, doesn't need ptrace at all, and works on every Linux system you'll ever touch: the /proc and /sys pseudo-filesystems.

The idea behind /proc is so elegant it's almost funny. Instead of providing special system calls to query process information (which is what Windows does with its labyrinth of undocumented NtQuerySystemInformation calls), Linux just... makes everything look like files. Want to know how much memory a process is using? Read a file. Want to see which TCP connections are open? Read a file. Want to know the CPU model? You guessed it -- read a file.

This means every tool you already have for reading files works instantly for system inspection. cat, grep, our Zig file I/O from episode 10 -- all of it. No special APIs, no libraries, no permissions (mostly). And because /proc entries are generated on the fly by the kernel, they're always current -- there's no stale cache to worry about ;-)

The /proc filesystem

/proc is a virtual filesystem -- nothing on disk backs it. When you read /proc/meminfo, the kernel generates the content at read time from its internal data structures. When you close the file, the content disappears. It exists purely in memory, created and destroyed on demand.

Every running process gets a directory at /proc/[pid]/ containing files that describe that process. Your own process is always available at /proc/self/ (a symlink to your actual PID directory). And there are global files like /proc/cpuinfo, /proc/meminfo, /proc/uptime that report system-wide information.

Here's how to read a basic /proc file in Zig:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // read /proc/uptime -- two numbers: uptime and idle time in seconds
    const uptime_file = try std.fs.openFileAbsolute("/proc/uptime", .{});
    defer uptime_file.close();
    var buf: [128]u8 = undefined;
    const n = try uptime_file.readAll(&buf);
    const content = buf[0..n];

    try stdout.print("Raw /proc/uptime: {s}\n", .{content});

    // parse the two floating-point values
    var it = std.mem.splitScalar(u8, std.mem.trimRight(u8, content, "\n"), ' ');
    if (it.next()) |up_str| {
        // parse as integer seconds (ignore fractional)
        var dot_it = std.mem.splitScalar(u8, up_str, '.');
        if (dot_it.next()) |secs_str| {
            const secs = try std.fmt.parseInt(u64, secs_str, 10);
            const hours = secs / 3600;
            const mins = (secs % 3600) / 60;
            try stdout.print("Uptime: {d}h {d}m\n", .{ hours, mins });
        }
    }

    // read our own PID via /proc/self
    const self_stat = try std.fs.openFileAbsolute("/proc/self/stat", .{});
    defer self_stat.close();
    var stat_buf: [512]u8 = undefined;
    const sn = try self_stat.readAll(&stat_buf);
    const stat_content = stat_buf[0..sn];

    // first field is PID
    var stat_it = std.mem.splitScalar(u8, stat_content, ' ');
    if (stat_it.next()) |pid_str| {
        try stdout.print("Our PID (from /proc/self/stat): {s}\n", .{pid_str});
    }
}

The beauty here is that this is just standard file I/O -- the same openFileAbsolute, readAll, close pattern we've been using since episode 10. No special system calls, no ptrace, no elevated privileges.

Reading /proc/self/maps for memory layout

Every process has a memory map at /proc/[pid]/maps that shows every mapped region of virtual memory. This is the same information GDB shows when you run info proc mappings. Each line contains the address range, permissions, offset, device, inode, and (optionally) the pathname:

const std = @import("std");

const MemRegion = struct {
    start: u64,
    end: u64,
    perms: [4]u8,
    offset: u64,
    path: []const u8,
};

fn parseMapLine(line: []const u8) ?MemRegion {
    var it = std.mem.splitScalar(u8, line, ' ');

    // field 1: address range "start-end"
    const addr_range = it.next() orelse return null;
    var addr_it = std.mem.splitScalar(u8, addr_range, '-');
    const start_str = addr_it.next() orelse return null;
    const end_str = addr_it.next() orelse return null;
    const start = std.fmt.parseInt(u64, start_str, 16) catch return null;
    const end = std.fmt.parseInt(u64, end_str, 16) catch return null;

    // field 2: permissions "rwxp" or "r--s" etc
    const perms_str = it.next() orelse return null;
    if (perms_str.len < 4) return null;
    var perms: [4]u8 = undefined;
    @memcpy(&perms, perms_str[0..4]);

    // field 3: offset
    const offset_str = it.next() orelse return null;
    const offset = std.fmt.parseInt(u64, offset_str, 16) catch return null;

    // fields 4,5: device and inode (skip)
    _ = it.next(); // device
    _ = it.next(); // inode

    // field 6 (optional): pathname -- everything remaining
    // skip leading spaces from the split
    const rest = it.rest();
    const path = std.mem.trimLeft(u8, rest, " ");

    return .{
        .start = start,
        .end = end,
        .perms = perms,
        .offset = offset,
        .path = path,
    };
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const file = try std.fs.openFileAbsolute("/proc/self/maps", .{});
    defer file.close();

    var buf: [16384]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    try stdout.print("Memory map for PID self:\n\n", .{});
    try stdout.print("{s:<26} {s:<6} {s:<10} {s}\n", .{ "Address Range", "Perms", "Size", "Path" });
    try stdout.print("{s}\n", .{"-" ** 80});

    var total_mapped: u64 = 0;
    var executable_regions: u32 = 0;
    var line_it = std.mem.splitScalar(u8, content, '\n');

    while (line_it.next()) |line| {
        if (line.len == 0) continue;
        if (parseMapLine(line)) |region| {
            const size = region.end - region.start;
            total_mapped += size;
            if (region.perms[2] == 'x') executable_regions += 1;

            const size_kb = size / 1024;
            try stdout.print("0x{x:0>12}-0x{x:0>12} {s} {d:>6}KB {s}\n", .{
                region.start, region.end, &region.perms, size_kb, region.path,
            });
        }
    }

    try stdout.print("\nTotal mapped: {d} KB ({d} MB)\n", .{
        total_mapped / 1024, total_mapped / (1024 * 1024),
    });
    try stdout.print("Executable regions: {d}\n", .{executable_regions});
}

The permissions field (rwxp) tells you exactly what the process can do with each region. The fourth character is p for private (copy-on-write) or s for shared. When you see r-xp that's executable code (text segment), rw-p is writable data (stack, heap, BSS), and r--p is read-only data. This is the foundation of memory forensics -- if you see a rwxp region (read, write, AND execute), that's suspicious because legitimate programs rarely need writable executable memory.

Process details: status, stat, and cmdline

Three files in /proc/[pid]/ give you the most important process information. /proc/[pid]/status is human-readable key-value pairs. /proc/[pid]/stat is a single line of space-separated fields (optimized for parsing, not readability). /proc/[pid]/cmdline contains the command line arguments separated by null bytes:

const std = @import("std");

const ProcessInfo = struct {
    pid: u32,
    name: [64]u8,
    name_len: usize,
    state: u8,
    ppid: u32,
    vm_size_kb: u64,
    vm_rss_kb: u64,
    threads: u32,
    uid: u32,
    cmdline: [256]u8,
    cmdline_len: usize,
};

fn readProcFile(pid: u32, filename: []const u8, buf: []u8) ![]u8 {
    var path_buf: [64]u8 = undefined;
    const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/{s}", .{ pid, filename }) catch return error.PathTooLong;
    const file = try std.fs.openFileAbsolute(path, .{});
    defer file.close();
    const n = try file.readAll(buf);
    return buf[0..n];
}

fn parseKeyValue(content: []const u8, key: []const u8) ?[]const u8 {
    var line_it = std.mem.splitScalar(u8, content, '\n');
    while (line_it.next()) |line| {
        if (std.mem.startsWith(u8, line, key)) {
            const after_key = line[key.len..];
            return std.mem.trimLeft(u8, after_key, " \t");
        }
    }
    return null;
}

fn getProcessInfo(pid: u32) !ProcessInfo {
    var info = ProcessInfo{
        .pid = pid, .name = undefined, .name_len = 0,
        .state = '?', .ppid = 0, .vm_size_kb = 0,
        .vm_rss_kb = 0, .threads = 0, .uid = 0,
        .cmdline = undefined, .cmdline_len = 0,
    };

    // parse /proc/[pid]/status
    var status_buf: [4096]u8 = undefined;
    const status = try readProcFile(pid, "status", &status_buf);

    if (parseKeyValue(status, "Name:")) |name| {
        const len = @min(name.len, 64);
        @memcpy(info.name[0..len], name[0..len]);
        info.name_len = len;
    }
    if (parseKeyValue(status, "State:")) |s| {
        if (s.len > 0) info.state = s[0];
    }
    if (parseKeyValue(status, "PPid:")) |v| {
        info.ppid = std.fmt.parseInt(u32, std.mem.trim(u8, v, " \t"), 10) catch 0;
    }
    if (parseKeyValue(status, "VmSize:")) |v| {
        var it = std.mem.splitScalar(u8, std.mem.trim(u8, v, " \t"), ' ');
        if (it.next()) |num| info.vm_size_kb = std.fmt.parseInt(u64, num, 10) catch 0;
    }
    if (parseKeyValue(status, "VmRSS:")) |v| {
        var it = std.mem.splitScalar(u8, std.mem.trim(u8, v, " \t"), ' ');
        if (it.next()) |num| info.vm_rss_kb = std.fmt.parseInt(u64, num, 10) catch 0;
    }
    if (parseKeyValue(status, "Threads:")) |v| {
        info.threads = std.fmt.parseInt(u32, std.mem.trim(u8, v, " \t"), 10) catch 0;
    }
    if (parseKeyValue(status, "Uid:")) |v| {
        var it = std.mem.splitScalar(u8, std.mem.trim(u8, v, " \t"), '\t');
        if (it.next()) |uid_str| info.uid = std.fmt.parseInt(u32, uid_str, 10) catch 0;
    }

    // read /proc/[pid]/cmdline (null-separated args)
    var cmd_buf: [256]u8 = undefined;
    const cmdline = readProcFile(pid, "cmdline", &cmd_buf) catch &[0]u8{};
    // replace nulls with spaces for display
    for (0..cmdline.len) |i| {
        info.cmdline[i] = if (cmdline[i] == 0) ' ' else cmdline[i];
    }
    info.cmdline_len = cmdline.len;

    return info;
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // list a few interesting processes
    const pids_to_check = [_]u32{ 1, @intCast(std.os.linux.syscall0(.getpid)) };

    for (pids_to_check) |pid| {
        const info = getProcessInfo(pid) catch |err| {
            try stdout.print("PID {d}: {s}\n", .{ pid, @errorName(err) });
            continue;
        };
        try stdout.print("PID {d}:\n", .{info.pid});
        try stdout.print("  Name:     {s}\n", .{info.name[0..info.name_len]});
        try stdout.print("  State:    {c}\n", .{info.state});
        try stdout.print("  Parent:   {d}\n", .{info.ppid});
        try stdout.print("  UID:      {d}\n", .{info.uid});
        try stdout.print("  VmSize:   {d} KB\n", .{info.vm_size_kb});
        try stdout.print("  VmRSS:    {d} KB\n", .{info.vm_rss_kb});
        try stdout.print("  Threads:  {d}\n", .{info.threads});
        try stdout.print("  Cmdline:  {s}\n\n", .{info.cmdline[0..info.cmdline_len]});
    }
}

The status file is human-friendly with "Key:\tValue" format. The stat file is faster to parse (single line, space-separated) but harder to read. In practice, most tools read status unless they need maximum performance -- the kernel generates both from the same internal structures anyway.

Having said that, there's a subtlety with cmdline that trips people up: the arguments are separated by null bytes, not spaces or newlines. And if the process modified its own argv (which some daemons do to show status in ps output), the cmdline reflects those modifications. It's not a historical record -- it's a live view of the process's argument memory.

Parsing /proc/net for network connections

/proc/net/tcp and /proc/net/udp contain the kernel's view of all TCP and UDP sockets. The format is... well, it's hexadecimal IP addresses and port numbers crammed into fixed-width columns. Not exactly user-friendly, but very parseable once you understand the layout:

const std = @import("std");

const TcpConnection = struct {
    local_addr: u32,
    local_port: u16,
    remote_addr: u32,
    remote_port: u16,
    state: u8,
    uid: u32,
    inode: u64,
};

fn parseHexIp(hex: []const u8) ?u32 {
    return std.fmt.parseInt(u32, hex, 16) catch null;
}

fn parseHexPort(hex: []const u8) ?u16 {
    return std.fmt.parseInt(u16, hex, 16) catch null;
}

fn formatIp(addr: u32) [15]u8 {
    // /proc/net/tcp stores IPs in host byte order (little-endian on x86)
    var buf: [15]u8 = [_]u8{' '} ** 15;
    _ = std.fmt.bufPrint(&buf, "{d}.{d}.{d}.{d}", .{
        addr & 0xFF, (addr >> 8) & 0xFF,
        (addr >> 16) & 0xFF, (addr >> 24) & 0xFF,
    }) catch {};
    return buf;
}

fn tcpStateName(state: u8) []const u8 {
    return switch (state) {
        0x01 => "ESTABLISHED",
        0x02 => "SYN_SENT",
        0x03 => "SYN_RECV",
        0x04 => "FIN_WAIT1",
        0x05 => "FIN_WAIT2",
        0x06 => "TIME_WAIT",
        0x07 => "CLOSE",
        0x08 => "CLOSE_WAIT",
        0x09 => "LAST_ACK",
        0x0A => "LISTEN",
        0x0B => "CLOSING",
        else => "UNKNOWN",
    };
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const file = try std.fs.openFileAbsolute("/proc/net/tcp", .{});
    defer file.close();
    var buf: [32768]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    try stdout.print("{s:<22} {s:<22} {s:<14} {s}\n", .{
        "Local Address", "Remote Address", "State", "UID",
    });
    try stdout.print("{s}\n", .{"-" ** 70});

    var line_it = std.mem.splitScalar(u8, content, '\n');
    _ = line_it.next(); // skip header line

    var listen_count: u32 = 0;
    var established_count: u32 = 0;

    while (line_it.next()) |line| {
        if (line.len < 10) continue;
        const trimmed = std.mem.trimLeft(u8, line, " ");

        // split by whitespace: sl, local_address, rem_address, st, ...
        var field_it = std.mem.tokenizeAny(u8, trimmed, " ");
        _ = field_it.next(); // sl (slot number)
        const local = field_it.next() orelse continue;
        const remote = field_it.next() orelse continue;
        const state_hex = field_it.next() orelse continue;
        _ = field_it.next(); // tx_queue:rx_queue
        _ = field_it.next(); // tr:tm->when
        _ = field_it.next(); // retrnsmt
        const uid_str = field_it.next() orelse continue;

        // parse local address:port
        var local_it = std.mem.splitScalar(u8, local, ':');
        const local_ip_hex = local_it.next() orelse continue;
        const local_port_hex = local_it.next() orelse continue;
        const local_ip = parseHexIp(local_ip_hex) orelse continue;
        const local_port = parseHexPort(local_port_hex) orelse continue;

        // parse remote address:port
        var remote_it = std.mem.splitScalar(u8, remote, ':');
        const remote_ip_hex = remote_it.next() orelse continue;
        const remote_port_hex = remote_it.next() orelse continue;
        const remote_ip = parseHexIp(remote_ip_hex) orelse continue;
        const remote_port = parseHexPort(remote_port_hex) orelse continue;

        const state = std.fmt.parseInt(u8, state_hex, 16) catch continue;
        const uid = std.fmt.parseInt(u32, uid_str, 10) catch 0;

        if (state == 0x0A) listen_count += 1;
        if (state == 0x01) established_count += 1;

        const lip = formatIp(local_ip);
        const rip = formatIp(remote_ip);
        try stdout.print("{s}:{d:<5}  {s}:{d:<5}  {s:<14} {d}\n", .{
            &lip, local_port, &rip, remote_port, tcpStateName(state), uid,
        });
    }

    try stdout.print("\nListening: {d}, Established: {d}\n", .{ listen_count, established_count });
}

Notice the IP addresses are stored in host byte order (little-endian on x86-64), which means the bytes are reversed compared to what you'd expect. 0100007F is 127.0.0.1 because you read the bytes right-to-left: 7F=127, 00=0, 00=0, 01=1. This catches everyone the first time they try to parse this file -- I know it caught me ;-)

The UID field tells you which user owns the socket. UID 0 is root, and you can cross-reference with /etc/passwd to get the username. The netstat and ss commands read exactly these files under the hood.

The /sys filesystem: hardware and drivers

While /proc focuses on processes and kernel state, /sys exposes the kernel's device model -- hardware, drivers, buses, and their attributes. It's organized as a tree reflecting the physical and logical device hierarchy:

const std = @import("std");

fn readSysFile(path: []const u8, buf: []u8) ![]u8 {
    const file = std.fs.openFileAbsolute(path, .{}) catch return error.NotFound;
    defer file.close();
    const n = try file.readAll(buf);
    // trim trailing newline
    if (n > 0 and buf[n - 1] == '\n') return buf[0 .. n - 1];
    return buf[0..n];
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    var buf: [256]u8 = undefined;

    try stdout.print("=== System Information from /sys ===\n\n", .{});

    // DMI/BIOS information
    if (readSysFile("/sys/class/dmi/id/sys_vendor", &buf)) |v|
        try stdout.print("Vendor:       {s}\n", .{v})
    else |_| {}

    if (readSysFile("/sys/class/dmi/id/product_name", &buf)) |v|
        try stdout.print("Product:      {s}\n", .{v})
    else |_| {}

    if (readSysFile("/sys/class/dmi/id/bios_version", &buf)) |v|
        try stdout.print("BIOS:         {s}\n", .{v})
    else |_| {}

    // thermal information
    try stdout.print("\n--- Thermal Zones ---\n", .{});
    var zone: u32 = 0;
    while (zone < 10) : (zone += 1) {
        var path_buf: [128]u8 = undefined;
        const type_path = std.fmt.bufPrint(&path_buf, "/sys/class/thermal/thermal_zone{d}/type", .{zone}) catch break;
        const zone_type = readSysFile(type_path, &buf) catch break;

        var temp_path_buf: [128]u8 = undefined;
        const temp_path = std.fmt.bufPrint(&temp_path_buf, "/sys/class/thermal/thermal_zone{d}/temp", .{zone}) catch break;
        if (readSysFile(temp_path, &buf)) |temp_str| {
            const temp_milli = std.fmt.parseInt(i64, temp_str, 10) catch 0;
            try stdout.print("  Zone {d} ({s}): {d}.{d} C\n", .{
                zone, zone_type,
                @divTrunc(temp_milli, 1000),
                @as(u64, @intCast(@mod(@abs(temp_milli), 1000))) / 100,
            });
        } else |_| {}
    }

    // block devices
    try stdout.print("\n--- Block Devices ---\n", .{});
    const block_dir = std.fs.openDirAbsolute("/sys/block", .{ .iterate = true }) catch return;
    var block_it = block_dir.iterate();
    while (try block_it.next()) |entry| {
        if (std.mem.startsWith(u8, entry.name, "loop")) continue; // skip loop devices
        var size_path_buf: [128]u8 = undefined;
        const size_path = std.fmt.bufPrint(&size_path_buf, "/sys/block/{s}/size", .{entry.name}) catch continue;
        if (readSysFile(size_path, &buf)) |size_str| {
            const sectors = std.fmt.parseInt(u64, size_str, 10) catch 0;
            const gb = (sectors * 512) / (1024 * 1024 * 1024);
            try stdout.print("  {s}: {d} GB\n", .{ entry.name, gb });
        } else |_| {}
    }

    // network interfaces
    try stdout.print("\n--- Network Interfaces ---\n", .{});
    const net_dir = std.fs.openDirAbsolute("/sys/class/net", .{ .iterate = true }) catch return;
    var net_it = net_dir.iterate();
    while (try net_it.next()) |entry| {
        var state_path_buf: [128]u8 = undefined;
        const state_path = std.fmt.bufPrint(&state_path_buf, "/sys/class/net/{s}/operstate", .{entry.name}) catch continue;
        const state = readSysFile(state_path, &buf) catch "unknown";
        var mtu_path_buf: [128]u8 = undefined;
        const mtu_path = std.fmt.bufPrint(&mtu_path_buf, "/sys/class/net/{s}/mtu", .{entry.name}) catch continue;
        const mtu = readSysFile(mtu_path, &buf) catch "?";
        try stdout.print("  {s}: state={s} mtu={s}\n", .{ entry.name, state, mtu });
    }
}

A few things to note about /sys versus /proc. The /sys filesystem has a very strict rule: one value per file. Each file contains exactly one attribute. This is the opposite of /proc where files like /proc/meminfo dump dozens of values at once. The /sys convention makes it trivial to read a single attribute but requires more file opens for a complete picture. It's a design tradeoff -- /sys was introduced in Linux 2.6 specifically to fix the "parse a blob of text" problem that /proc had.

CPU and memory: /proc/cpuinfo and /proc/meminfo

These two files are probably the most-read files in /proc. Every monitoring tool, every cloud instance metadata script, every "what hardware am I running on" script reads them:

const std = @import("std");

const CpuInfo = struct {
    model_name: [128]u8 = undefined,
    model_name_len: usize = 0,
    cores: u32 = 0,
    mhz: [16]u8 = undefined,
    mhz_len: usize = 0,
    cache_size: [32]u8 = undefined,
    cache_size_len: usize = 0,
};

const MemInfo = struct {
    total_kb: u64 = 0,
    free_kb: u64 = 0,
    available_kb: u64 = 0,
    buffers_kb: u64 = 0,
    cached_kb: u64 = 0,
    swap_total_kb: u64 = 0,
    swap_free_kb: u64 = 0,
};

fn parseMemValue(line: []const u8) u64 {
    // format: "MemTotal:       16384000 kB"
    const colon_pos = std.mem.indexOfScalar(u8, line, ':') orelse return 0;
    const after = std.mem.trimLeft(u8, line[colon_pos + 1 ..], " ");
    var it = std.mem.splitScalar(u8, after, ' ');
    const num_str = it.next() orelse return 0;
    return std.fmt.parseInt(u64, num_str, 10) catch 0;
}

fn getCpuInfo() !CpuInfo {
    var info = CpuInfo{};

    const file = try std.fs.openFileAbsolute("/proc/cpuinfo", .{});
    defer file.close();
    var buf: [16384]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    var line_it = std.mem.splitScalar(u8, content, '\n');
    while (line_it.next()) |line| {
        if (std.mem.startsWith(u8, line, "model name")) {
            if (info.model_name_len == 0) {
                const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
                const val = std.mem.trimLeft(u8, line[colon + 1 ..], " ");
                const len = @min(val.len, 128);
                @memcpy(info.model_name[0..len], val[0..len]);
                info.model_name_len = len;
            }
            info.cores += 1; // each "processor" block has a model name
        }
        if (std.mem.startsWith(u8, line, "cpu MHz")) {
            if (info.mhz_len == 0) {
                const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
                const val = std.mem.trimLeft(u8, line[colon + 1 ..], " ");
                const len = @min(val.len, 16);
                @memcpy(info.mhz[0..len], val[0..len]);
                info.mhz_len = len;
            }
        }
        if (std.mem.startsWith(u8, line, "cache size")) {
            if (info.cache_size_len == 0) {
                const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
                const val = std.mem.trimLeft(u8, line[colon + 1 ..], " ");
                const len = @min(val.len, 32);
                @memcpy(info.cache_size[0..len], val[0..len]);
                info.cache_size_len = len;
            }
        }
    }
    return info;
}

fn getMemInfo() !MemInfo {
    var info = MemInfo{};

    const file = try std.fs.openFileAbsolute("/proc/meminfo", .{});
    defer file.close();
    var buf: [4096]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    var line_it = std.mem.splitScalar(u8, content, '\n');
    while (line_it.next()) |line| {
        if (std.mem.startsWith(u8, line, "MemTotal:")) info.total_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "MemFree:")) info.free_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "MemAvailable:")) info.available_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "Buffers:")) info.buffers_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "Cached:")) info.cached_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "SwapTotal:")) info.swap_total_kb = parseMemValue(line)
        else if (std.mem.startsWith(u8, line, "SwapFree:")) info.swap_free_kb = parseMemValue(line);
    }
    return info;
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const cpu = try getCpuInfo();
    try stdout.print("=== CPU ===\n", .{});
    try stdout.print("Model:  {s}\n", .{cpu.model_name[0..cpu.model_name_len]});
    try stdout.print("Cores:  {d}\n", .{cpu.cores});
    try stdout.print("MHz:    {s}\n", .{cpu.mhz[0..cpu.mhz_len]});
    try stdout.print("Cache:  {s}\n", .{cpu.cache_size[0..cpu.cache_size_len]});

    const mem = try getMemInfo();
    const used_kb = mem.total_kb - mem.available_kb;
    const usage_pct = if (mem.total_kb > 0) (used_kb * 100) / mem.total_kb else 0;
    try stdout.print("\n=== Memory ===\n", .{});
    try stdout.print("Total:     {d} MB\n", .{mem.total_kb / 1024});
    try stdout.print("Used:      {d} MB ({d}%%)\n", .{ used_kb / 1024, usage_pct });
    try stdout.print("Free:      {d} MB\n", .{mem.free_kb / 1024});
    try stdout.print("Available: {d} MB\n", .{mem.available_kb / 1024});
    try stdout.print("Buffers:   {d} MB\n", .{mem.buffers_kb / 1024});
    try stdout.print("Cached:    {d} MB\n", .{mem.cached_kb / 1024});
    if (mem.swap_total_kb > 0) {
        const swap_used = mem.swap_total_kb - mem.swap_free_kb;
        try stdout.print("Swap:      {d}/{d} MB\n", .{ swap_used / 1024, mem.swap_total_kb / 1024 });
    }
}

One thing worth pointing out: MemFree and MemAvailable are NOT the same thing. MemFree is memory that's completely unused -- no pages at all. MemAvailable is what the kernel estimates can be made available without swapping, which includes reclaimable caches and buffers. When people say "Linux uses all the RAM" they're usually looking at MemFree being low, but MemAvailable shows the real picture. The kernel actively caches file data in free memory (because why not?) and will happily give it back when a program needs it.

Parsing the various formats

The annoying truth about /proc is that there's no single format. Each file was added by a different kernel developer at a different time, and nobody enforced consistency. You'll encounter at least four distinct formats:

const std = @import("std");

// Format 1: "Key:\tValue" (most /proc/[pid] files)
fn parseColonFormat(content: []const u8, alloc: std.mem.Allocator) !std.StringHashMap([]const u8) {
    var map = std.StringHashMap([]const u8).init(alloc);
    var line_it = std.mem.splitScalar(u8, content, '\n');
    while (line_it.next()) |line| {
        const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
        const key = std.mem.trimRight(u8, line[0..colon], " \t");
        const value = std.mem.trimLeft(u8, line[colon + 1 ..], " \t");
        try map.put(key, value);
    }
    return map;
}

// Format 2: space-separated fields (e.g. /proc/[pid]/stat)
fn parseStatFormat(content: []const u8) struct { pid: u32, state: u8, vsize: u64 } {
    // stat is tricky because field 2 (comm) can contain spaces inside parens
    const lparen = std.mem.indexOfScalar(u8, content, '(') orelse return .{ .pid = 0, .state = '?', .vsize = 0 };
    const rparen = std.mem.lastIndexOfScalar(u8, content, ')') orelse return .{ .pid = 0, .state = '?', .vsize = 0 };

    const pid_str = std.mem.trim(u8, content[0..lparen], " ");
    const pid = std.fmt.parseInt(u32, pid_str, 10) catch 0;

    const after_comm = content[rparen + 2 ..]; // skip ") "
    var it = std.mem.splitScalar(u8, after_comm, ' ');
    const state_str = it.next() orelse return .{ .pid = pid, .state = '?', .vsize = 0 };
    const state = if (state_str.len > 0) state_str[0] else '?';

    // skip fields 4-22 to get vsize (field 23, index 20 after state)
    var skip: u32 = 0;
    while (skip < 19) : (skip += 1) _ = it.next();
    const vsize_str = it.next() orelse return .{ .pid = pid, .state = state, .vsize = 0 };
    const vsize = std.fmt.parseInt(u64, vsize_str, 10) catch 0;

    return .{ .pid = pid, .state = state, .vsize = vsize };
}

// Format 3: hex-encoded fields (/proc/net/tcp)
fn parseHexAddr(hex: []const u8) struct { ip: u32, port: u16 } {
    var it = std.mem.splitScalar(u8, hex, ':');
    const ip_hex = it.next() orelse return .{ .ip = 0, .port = 0 };
    const port_hex = it.next() orelse return .{ .ip = 0, .port = 0 };
    return .{
        .ip = std.fmt.parseInt(u32, ip_hex, 16) catch 0,
        .port = std.fmt.parseInt(u16, port_hex, 16) catch 0,
    };
}

// Format 4: single-value sysfs files (/sys)
fn readSysValue(comptime T: type, path: []const u8) !T {
    const file = try std.fs.openFileAbsolute(path, .{});
    defer file.close();
    var buf: [64]u8 = undefined;
    const n = try file.readAll(&buf);
    const trimmed = std.mem.trimRight(u8, buf[0..n], " \n\t");
    return std.fmt.parseInt(T, trimmed, 10);
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // demo format 1: colon-separated
    try stdout.print("=== Format 1: Colon-Separated ===\n", .{});
    const status_file = try std.fs.openFileAbsolute("/proc/self/status", .{});
    defer status_file.close();
    var status_buf: [4096]u8 = undefined;
    const sn = try status_file.readAll(&status_buf);
    var map = try parseColonFormat(status_buf[0..sn], std.heap.page_allocator);
    defer map.deinit();
    if (map.get("Name")) |v| try stdout.print("  Name: {s}\n", .{v});
    if (map.get("VmRSS")) |v| try stdout.print("  RSS:  {s}\n", .{v});

    // demo format 2: stat format
    try stdout.print("\n=== Format 2: Stat Fields ===\n", .{});
    const stat_file = try std.fs.openFileAbsolute("/proc/self/stat", .{});
    defer stat_file.close();
    var stat_buf: [512]u8 = undefined;
    const stat_n = try stat_file.readAll(&stat_buf);
    const stat = parseStatFormat(stat_buf[0..stat_n]);
    try stdout.print("  PID={d} State={c} VSize={d}\n", .{ stat.pid, stat.state, stat.vsize });

    // demo format 4: single-value sysfs
    try stdout.print("\n=== Format 4: Sysfs Single-Value ===\n", .{});
    if (readSysValue(u64, "/sys/class/net/lo/mtu")) |mtu|
        try stdout.print("  lo MTU: {d}\n", .{mtu})
    else |_|
        try stdout.print("  lo MTU: unavailable\n", .{});
}

The /proc/[pid]/stat format deserves special mention because it has a nasty parsing bug that bites everyone at least once. Field 2 is the process name enclosed in parentheses, and process names CAN contain spaces and even closing parentheses. So you can't just split by spaces -- you need to find the LAST closing parenthesis first, then split the fields after that. Grep for "last index of ')'" in any C/Go/Python tool that parses stat and you'll see everyone deals with this exact same quirk.

Practical example: a system info tool

Let's tie it all together with a complete system information tool that combines /proc, /sys, and everything we've covered into one useful utility:

const std = @import("std");

fn readFile(path: []const u8, buf: []u8) ![]u8 {
    const file = std.fs.openFileAbsolute(path, .{}) catch return error.NotFound;
    defer file.close();
    const n = try file.readAll(buf);
    return std.mem.trimRight(u8, buf[0..n], "\n \t");
}

fn findValue(content: []const u8, key: []const u8) ?[]const u8 {
    var it = std.mem.splitScalar(u8, content, '\n');
    while (it.next()) |line| {
        if (std.mem.startsWith(u8, line, key)) {
            const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
            return std.mem.trimLeft(u8, line[colon + 1 ..], " \t");
        }
    }
    return null;
}

fn parseKb(s: ?[]const u8) u64 {
    const val = s orelse return 0;
    var it = std.mem.splitScalar(u8, val, ' ');
    const num = it.next() orelse return 0;
    return std.fmt.parseInt(u64, num, 10) catch 0;
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    var buf: [16384]u8 = undefined;
    var small: [256]u8 = undefined;

    try stdout.print(
        \\=============================================
        \\           SYSTEM INFORMATION
        \\=============================================
        \\
    , .{});

    // hostname
    if (readFile("/proc/sys/kernel/hostname", &small)) |h|
        try stdout.print("Hostname:    {s}\n", .{h})
    else |_| {}

    // kernel version
    if (readFile("/proc/sys/kernel/osrelease", &small)) |k|
        try stdout.print("Kernel:      {s}\n", .{k})
    else |_| {}

    // uptime
    if (readFile("/proc/uptime", &small)) |up| {
        var it = std.mem.splitScalar(u8, up, '.');
        if (it.next()) |secs_str| {
            const secs = std.fmt.parseInt(u64, secs_str, 10) catch 0;
            const days = secs / 86400;
            const hours = (secs % 86400) / 3600;
            const mins = (secs % 3600) / 60;
            try stdout.print("Uptime:      {d}d {d}h {d}m\n", .{ days, hours, mins });
        }
    } else |_| {}

    // load average
    if (readFile("/proc/loadavg", &small)) |load|
        try stdout.print("Load:        {s}\n", .{load})
    else |_| {}

    // CPU
    try stdout.print("\n--- CPU ---\n", .{});
    const cpu_content = readFile("/proc/cpuinfo", &buf) catch "";
    if (findValue(cpu_content, "model name")) |model|
        try stdout.print("Model:       {s}\n", .{model});
    var cores: u32 = 0;
    var cpu_it = std.mem.splitScalar(u8, cpu_content, '\n');
    while (cpu_it.next()) |line| {
        if (std.mem.startsWith(u8, line, "processor")) cores += 1;
    }
    try stdout.print("Cores:       {d}\n", .{cores});

    // memory
    try stdout.print("\n--- Memory ---\n", .{});
    const mem_content = readFile("/proc/meminfo", &buf) catch "";
    const total = parseKb(findValue(mem_content, "MemTotal:"));
    const avail = parseKb(findValue(mem_content, "MemAvailable:"));
    const swap_total = parseKb(findValue(mem_content, "SwapTotal:"));
    const swap_free = parseKb(findValue(mem_content, "SwapFree:"));
    const used = total - avail;
    try stdout.print("Total:       {d} MB\n", .{total / 1024});
    try stdout.print("Used:        {d} MB ({d}%%)\n", .{ used / 1024, if (total > 0) (used * 100) / total else 0 });
    try stdout.print("Available:   {d} MB\n", .{avail / 1024});
    if (swap_total > 0) {
        try stdout.print("Swap:        {d}/{d} MB\n", .{ (swap_total - swap_free) / 1024, swap_total / 1024 });
    }

    // disk
    try stdout.print("\n--- Storage ---\n", .{});
    const block_dir = std.fs.openDirAbsolute("/sys/block", .{ .iterate = true }) catch return;
    var blk_it = block_dir.iterate();
    while (try blk_it.next()) |entry| {
        if (std.mem.startsWith(u8, entry.name, "loop")) continue;
        if (std.mem.startsWith(u8, entry.name, "ram")) continue;
        var path_buf: [128]u8 = undefined;
        const p = std.fmt.bufPrint(&path_buf, "/sys/block/{s}/size", .{entry.name}) catch continue;
        if (readFile(p, &small)) |size_str| {
            const sectors = std.fmt.parseInt(u64, size_str, 10) catch continue;
            if (sectors == 0) continue;
            const gb = (sectors * 512) / (1024 * 1024 * 1024);
            try stdout.print("{s:<10}   {d} GB\n", .{ entry.name, gb });
        } else |_| {}
    }

    // network
    try stdout.print("\n--- Network ---\n", .{});
    const net_dir = std.fs.openDirAbsolute("/sys/class/net", .{ .iterate = true }) catch return;
    var net_it = net_dir.iterate();
    while (try net_it.next()) |entry| {
        var path_buf: [128]u8 = undefined;
        const op_path = std.fmt.bufPrint(&path_buf, "/sys/class/net/{s}/operstate", .{entry.name}) catch continue;
        const state = readFile(op_path, &small) catch "?";
        var rx_path_buf: [128]u8 = undefined;
        const rx_path = std.fmt.bufPrint(&rx_path_buf, "/sys/class/net/{s}/statistics/rx_bytes", .{entry.name}) catch continue;
        const rx = readFile(rx_path, &small) catch "0";
        var tx_path_buf: [128]u8 = undefined;
        const tx_path = std.fmt.bufPrint(&tx_path_buf, "/sys/class/net/{s}/statistics/tx_bytes", .{entry.name}) catch continue;
        const tx = readFile(tx_path, &small) catch "0";
        const rx_mb = (std.fmt.parseInt(u64, rx, 10) catch 0) / (1024 * 1024);
        const tx_mb = (std.fmt.parseInt(u64, tx, 10) catch 0) / (1024 * 1024);
        try stdout.print("{s:<10}   {s:<8} RX:{d}MB TX:{d}MB\n", .{ entry.name, state, rx_mb, tx_mb });
    }

    // process count
    var proc_count: u32 = 0;
    const proc_dir = std.fs.openDirAbsolute("/proc", .{ .iterate = true }) catch return;
    var proc_it = proc_dir.iterate();
    while (try proc_it.next()) |entry| {
        if (entry.name.len > 0 and entry.name[0] >= '0' and entry.name[0] <= '9') {
            proc_count += 1;
        }
    }
    try stdout.print("\n--- Processes ---\n", .{});
    try stdout.print("Running:     {d}\n", .{proc_count});

    try stdout.print("\n=============================================\n", .{});
}

This is essentially what tools like neofetch, screenfetch, and fastfetch do (minus the ASCII art logos). They read the same /proc and /sys files we just covered, parse them, and format the output. The only difference is presentation.

What I find elegant about this approach is that we didn't need a single specialized system call. No sysinfo(), no statvfs(), no getifaddrs(). Everything came from reading text files. This is the Unix philosophy at work: make everything a file, and let existing tools do the heavy lifting. And because Zig's file I/O is exactly as capable as C's (with better error handling, I might add), we lose nothing by going through /proc instead of using low-level syscalls.

The techniques from this episode combine naturally with what we've been building across the Linux systems programming arc. The process information from /proc connects directly to the fork/exec work from episode 64, the signal info from episode 67, the resource limits from episode 71, and the ptrace inspection from episode 74. Next time we'll pull quite some of these threads together into a practical monitoring tool.

Exercises

  1. Build a top-like process lister that reads /proc/[pid]/stat and /proc/[pid]/status for all numeric directories in /proc/, sorts processes by RSS memory usage (descending), and prints the top 15 processes showing PID, name, state, RSS (in MB), CPU time (utime+stime from stat, converted to seconds), and command line. Handle errors gracefully -- processes can disappear between listing the directory and reading their files.

  2. Write a network connection monitor that reads /proc/net/tcp and /proc/net/tcp6 every 2 seconds, compares the current snapshot to the previous one, and reports NEW connections (not present before) and CLOSED connections (present before but gone now). Format the output showing local address:port, remote address:port, state, and whether it's a new or closed connection. For tcp6, parse the 128-bit hex addresses into readable IPv6 format.

  3. Build a /proc filesystem explorer that takes a PID as argument and produces a comprehensive dump of that process: memory map (from maps, with region classification -- heap, stack, shared libraries, anonymous), open file descriptors (by listing and reading /proc/[pid]/fd/ symlinks), environment variables (from /proc/[pid]/environ, null-separated like cmdline), resource limits (from /proc/[pid]/limits), and the full cgroup hierarchy (from /proc/[pid]/cgroup). Output everything as structured text with clear sections.

Thanks for reading!

  • /proc is a virtual filesystem that exposes process and kernel state as readable text files -- no special APIs needed, just standard file I/O
  • Every process gets a /proc/[pid]/ directory with status, stat, cmdline, maps, fd/, and dozens more
  • /proc/self/ is a symlink to your own process directory -- use it for self-inspection without knowing your PID
  • /proc/net/tcp and /proc/net/udp contain ALL socket state in hex format -- this is what ss and netstat read
  • IP addresses in /proc/net files are in host byte order (reversed on little-endian x86) -- a classic gotcha
  • /sys uses the "one value per file" convention, unlike /proc's "big blob of text" approach -- each was designed in a different era
  • MemFree vs MemAvailable is the single most misunderstood metric in Linux monitoring -- Available includes reclaimable caches
  • The stat file parser must handle parentheses in process names by finding the LAST ) first -- field splitting before that point will fail on names with spaces
  • These techniques connect directly to the process management (ep64), signal handling (ep67), resource limits (ep71), and ptrace (ep74) work we've done throughout this systems programming arc

@scipio



0
0
0.000
0 comments