I’ve recently started an effort to learn more about operating systems, particularly Unix-like systems. Modern operating systems offer a lot of incredible capabilities for application programmers, and they can even provide unique ways of delivering applications. For example, containers are largely possible because of Linux capabilities such as namespaces and cgroups. However, I would like to spend some time digging into the foundations of operating systems so that I have a better framework with which to explore some of these unique, modern capabilities.
To start my exploration, I am using the online book xv6: a simple, Unix-like teaching operating system, along with the code available on GitHub. As the name suggests, xv6 is a minimal operating system, based on Unix (as are many other real-world operating systems today), designed for teaching and learning. It doesn’t include many of the key capabilities you would expect in a production-grade OS, but the foundational capabilities of an OS kernel are there.
In this series, I plan to document my key takeaways from the book as I progress through it. In addition, the book includes a number of exercises, and I may document some of my key findings from them as well. Below are my key takeaways from Chapter 1. If you’re curious about operating systems or just starting your own journey, I hope this series helps or inspires you!
Operating System Interfaces Link to heading
An operating system, particularly the OS kernel, is responsible for allowing multiple applications to run on a computer, sharing access to key system resources such as memory, processor time, and storage. It also provides some useful abstractions so that applications don’t need to have low-level understanding of the specifics of the hardware. For example, it would be unfortunate if each application had to know specifically how to read and write data to every possible type of hard disk drive. Instead, the OS provides a unified interface for reading and writing data, so that applications only need to understand how to communicate with the kernel, and then it takes care of interacting with hardware correctly.
One of the key parts of this unified interface is a system call. A kernel provides a number of different system calls, such as opening, closing, creating, or deleting files; creating or stopping processes; and interacting directly with devices. xv6 has a relatively small number of system calls, but fully featured, production-grade operating systems would have hundreds. The standard library of most programming languages will provide more convenient abstractions over direct system calls, in order to make it more convenient to write code that works on multiple operating systems.
Processes and Memory Link to heading
A process is a running instance of a program. One process can create a new one in xv6 using the fork
system call. (Other Unix-like systems have other mechanisms for creating processes as well.) Each process has a parent, and a parent process may have many children.
After forking, a child will be in the same state as the parent, including sharing copies of existing variables, open files, and it will even be on the same line of the program as the parent. There is a Unix convention that the Process ID (PID) following fork
is 0 inside the child process and a positive integer of the actual PID inside the parent. This is how a program can diverge behavior following a fork, depending on if it is the parent or child.
A process can replace itself with another by using the exec
system call. This can be tied nicely together with fork
. For example, in a shell application, when running an executable, the shell will fork
a child process and then exec
the application inside the child.
In general, a parent should not exit until its children have also exited. The wait
system call enables this, by allowing the parent to wait on one or more children to change their status.
I/O and File Descriptors Link to heading
A file descriptor is a small integer representing an object that an application may read from or write to. Each process has its own set of file descriptors (although, recall that a child process will also get a copy of the parent’s file descriptors).
Within a shell, three standard file descriptors are generally created: 0, also known as Standard Input or STDIN; 1, also known as Standard Output or STDOUT; and 2, also known as Standard Error or STDERR. Conventionally, STDIN is where a process reads input from, and STDOUT is where it writes its “normal” output. STDERR is used to write log messages.
Access to files and devices is also provided using file descriptors. For instance, using the open
system call, an application will be given a new file descriptor with which to read and write to a file. Incidentally, read
and write
are also system calls, which take a file descriptor, along with some additional parameters such as a buffer in which to write contents from a read
or to read contents on a write
.
When creating a new file descriptor, using open
, pipe
, or dup
, the next available, lowest number is returned. This means that if a program is written to close
STDIN, for example, and then open a file, its file descriptor will be 0 (the same as the default STDIN). The same is true for STDOUT or STDERR, of course. This is how I/O redirection works. If an application (say cat
) writes data to STDOUT (which applications do, by convention), then a shell can instead redirect output to a file by forking, closing STDOUT within the child process, opening a new file (which will have the same file descriptor as STDOUT, 1), and then executing the application. The application writes to file descriptor 1, but within this process, the kernel is actually directing that file descriptor to the output filename.
Pipes Link to heading
A pipe is a small, in-memory buffer, provided by the kernel, which allows two processes to communicate with one another. Using the pipe
system call, the kernel provides a “write” side and a “read” side of the pipe. This means that pipes are unidirectional. So, if two processes must both send and receive data between each other, then two pipes would be needed.
This is the foundation for the “pipe” in shells. For example, piping the output of one command into another relies on replacing the STDOUT of the first program with the write file descriptor of a pipe and the STDIN of the second program with the read file descriptor of the same pipe.
Filesystem Link to heading
An Operating System provides access to files, with directories containing files or other directories. In xv6, there is a single tree of directories, with the top level directory known as the “root” or /
. A process has a “current directory,” which is the path within the filesystem that the process is currently operating in. The process can change its current working directory, however a child process cannot change its parent’s current directory. (This is why the cd
command is actually built into the shell and is not a standalone executable.)
A file is NOT the same thing as the file’s name. The file object is known as an inode, and it can have multiple names that refer to it, known as links. The inode has metadata about itself, including whether it is a file or directory, its length, its location on disk, and the number of links it has. The fstat
system call can be used to get this metadata information about a file.
The unlink
system call removes a name from the filesystem. An inode and the disk space in which its contents exist can only be freed when all links to the inode have been removed.
Exercise: Parent/Child IPC with Pipes Link to heading
Chapter 1 in the xv6 book has an exercise that asks you to write a program that forks a child and then enables “ping-ponging” data between the two processes using a set of pipes. My implementation of the exercise is in a GitHub gist. I’ve tried to add comments and debug statements to show how all of the concepts described in the previous sections tie together to enable this.
The exercise was a great way for me to dust off some of my C skills. I tried to avoid doing too many Google searches for help with crafting calls to the C standard library and, instead, focused on spending some quality time with the man pages!
Although I ultimately changed the design a bit, one of the key things I learned from this exercise was the importance of closing file descriptors after duplicating them! I wanted the parent process to wait for the child to exit, but I found that my first attesmpts deadlocked. A key aspect of pipes is that they will only close when ALL file descriptors pointing to them are removed, much like how an inode can only be removed when all its links are removed. However, although I thought I had closed all of my file dcriptors, I forgot about some duplicates that came about after the fork.
Conclusion Link to heading
I appreciate that the book begins with an overview of how an application can make use of an operating system’s services by way of system calls, while also providing an overview of some of the key such calls. The chapter is very objective and full of facts, but I believe it sets the stage for the rest of my study of operating systems.
In Chapter 2, we’ll be moving on to Operating System Organization, diving a bit deeper into xv6 internals, and adding a system call of our own to it!