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

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):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys (this post)
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(®s));
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(®s));
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(®s));
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(®s)); // 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, ®ion.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
Build a top-like process lister that reads
/proc/[pid]/statand/proc/[pid]/statusfor 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.Write a network connection monitor that reads
/proc/net/tcpand/proc/net/tcp6every 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.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/tcpand/proc/net/udpcontain ALL socket state in hex format -- this is whatssandnetstatread- 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
MemFreevsMemAvailableis 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