Talking to the Operating System
Every chapter so far has been about the inside of your program: its bytes, its memory, its failures. This one is about the edge, because a program cannot actually do anything from in there. It cannot read a file, write to your terminal, or open a connection. It can only compute.
Anything that reaches the outside is done by asking the operating system, and the asking is a system call: your program stops, the kernel does the thing with privileges your program does not have, and control comes back with a result. That is the boundary, and everything you think of as I/O crosses it.
The shape of the arrangement is the part to keep, because it is older than almost everything else you use and it has not changed:
- The operating system hands your program some things when it starts, without being asked.
- Your program asks for anything else by number: it refers to what it opened with a small integer, not a name.
- When it ends, it hands back one number, and that number is the only thing the parent process is guaranteed to learn.
The program
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &stdout_writer.interface;
// Three streams were already open when main was entered. Nothing in this
// program opened them, and the numbers are not arbitrary: every process
// starts with the same three.
try out.print("stdin is descriptor {d}\n", .{std.Io.File.stdin().handle});
try out.print("stdout is descriptor {d}\n", .{std.Io.File.stdout().handle});
try out.print("stderr is descriptor {d}\n\n", .{std.Io.File.stderr().handle});
// They go to different places, which is the whole reason there are two
// output streams rather than one. This line is the program's result.
try out.writeAll("this line went to stdout: the program's output\n");
try out.flush();
// And this one is for the person watching. Redirect the program into a
// file and this still appears on the terminal.
var err_buf: [256]u8 = undefined;
var err_writer = std.Io.File.stderr().writerStreaming(io, &err_buf);
try err_writer.interface.writeAll("this line went to stderr: not part of the output\n");
try err_writer.interface.flush();
// Returning normally from main is a status of 0, which by convention
// means success. Returning an error would make it non-zero, and that is
// the only thing the parent process is guaranteed to learn.
try out.writeAll("\nreturning from main reports success to whoever ran us\n");
try out.flush();
}What just happened
Three streams were already open. The program did not open them and cannot choose their numbers. Descriptor 0 is standard input, 1 is standard output, 2 is standard error, and that is true of every process on every Unix-derived system, which is why a shell can wire two programs together without either of them agreeing to it in advance.
A descriptor is an integer and nothing else. It is not a handle object with
methods hiding inside it. It is a number the kernel uses to look up what you
opened, in a table it keeps for your process. Opening a file gives you the next
free number. This is one of the places where “everything is a file” stops being
a slogan: a file, a socket, a pipe and a terminal are all just numbers into
that table, and read works on all of them.
Two output streams, on purpose. stdout is the program’s result, the bytes
another program would consume if you piped this one into it. stderr is for the
person watching. The distinction only becomes visible when you redirect: run
this locally as zig run talking-to-the-os.zig > out.txt and the stdout lines
land in the file while the stderr line still appears on your terminal. Getting
this wrong is why some tools are painful to script.
Returning from main reported 0. A process ends with a status, and by
convention 0 means success and anything else means a specific kind of failure.
It is one small integer, which is not much to say with, and it is exactly why
programs also write to stderr: the status says that it failed, and stderr says
what happened.
Check yourself
This program ran in your browser, where there is no operating system, no process table and no kernel to call. So why did the descriptors print 0, 1 and 2?
Because the page runs it under WASI, an interface that keeps the same shape: the same numbering, the same calls, implemented in JavaScript against the browser instead of against a kernel. The program cannot tell the difference, which is the point of an interface, and it is the reason most of the snippets in this guide can run on a page at all. The five chapters in the OS section that genuinely need a kernel say so and do not offer a Run button.
If you have written C
You have already met all of this, under the names open, read, write,
close, and exit. Zig’s std.Io.File is a descriptor and a couple of flag
bits, and you can build one out of the number 1 by hand and write to standard
output with it. The file descriptors chapter does
exactly that.
Two differences show up immediately. A failed call returns an error rather than
setting a global errno, so it composes with try like everything else. And
the ability to do I/O arrives as an argument: main receives an init, and
init.io is what the writers above were built from. There is no global
stdout to reach for, which means a function that does not take an Io cannot
quietly touch the outside world.
Where to go next
That is the track. You now have the model the rest of the guide assumes: bytes and types, sizes and their cost, addresses and the stack, ownership, bounds and build modes, text as bytes, structs as layout, errors as values, and the boundary with the operating system.
If you arrived here before the rest of the guide,
Getting Started installs a compiler and gets these
programs running on your own machine, and Language Basics
covers the same ground from the language side: what *T means precisely, how
optionals remove null, what comptime is.
If you came the other way, having already read those, the section that pays off this one soonest is the operating system, which takes the descriptors above and opens files, spawns processes and wires up pipes with them. And if you would rather learn by building, Data Structures starts with a linked list, the smallest program that makes you answer the ownership question from chapter five for real.