⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

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.