CS 332 s20 — Introduction

Table of Contents

1 Optional Background Reading: Hardware Overview

It will be useful to have a basic familiarity with the primary hardware components of a computer system. In particular, the role of the processor, memory, and disk are critical. This reading provides a good brief overview. Please post any questions you have on the Moodle course forum or on Slack! It's important not to have lingering confusion about these basics.

2 Reading: What is an Operating System?

An operating system (OS) is a complex piece of software that fulfills many different roles in a computer system. Which roles get emphasized and even which are necessary vary considerably for different systems. This makes answering what is an operating system somewhat difficult. Possible answers include:

  • I don't know (though a few years ago I gave it a shot).
  • Nobody knows.
  • The assigned reading claims to know (and makes a number of good points)
  • They're programs—large, complicated programs
    • The Linux source has over 15M lines of C
    • Windows has way, way more…

Do the reading on what is an operating system? and then respond to the both question on this topic's reading response forum: how is an operating system different from a user application? and how does virtualization benefit application developers?


3 Examples of the OS in Action: a Preview of What's to Come

One of the key roles of the OS discussed in the reading is the allocation of resources. The processor (or CPU) is one of the resources the OS must coordinate and share. Consider the simple program below. All it does is call Spin(), a function that repeatedly checks the time and returns once it has run for a second. Then, it prints out the string that the user passed in on the command line, and repeats, forever.

/* cpu.c from https://github.com/remzi-arpacidusseau/ostep-code/tree/master/intro */
#include <stdio.h>
#include <stdlib.h>
#include "common.h"

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: cpu <string>\n");
        exit(1);
    }
    char *str = argv[1];

    while (1) {
        printf("%s\n", str);
        Spin(1);
    }
    return 0;
}

Let's say we compile and run this code on a system with a single CPU. Here's what we will see:

$ gcc -o cpu cpu.c -Wall
$ ./cpu "A"
A
A
A
A
^C
$

Not too interesting of a run — the system begins running the program, which repeatedly checks the time until a second has elapsed. Once a second has passed, the code prints the input string passed in by the user (in this example, the letter "A"), and continues. Note the program will run forever; by pressing "Control-c" (which on UNIX-based systems will terminate the program running in the foreground) we can halt the program.

Now, let’s do the same thing, but this time, let’s run many different instances of this same program.

$ ./cpu A & ./cpu B & ./cpu C & ./cpu D &
[1] 7353
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A
...

Well, now things are getting a little more interesting. Even though we have only one processor, somehow all four of these programs seem to be running at the same time! How does this magic happen?1

It turns out the operating system, with some help from the hardware, can share the CPU among many different running programs (referee), while providing each program the view that it's the only one using the CPU (illusionist). This capability is so central, that it touches on nearly every topic we will cover in this course.


Another aspect of the operating system as referee is facilitating communication between different applications. A common mechanism for this is shared memory. That is, data in memory that multiple applications can access, communicating by reading and writing to the same variable or data structure. I'll demonstrate this with an example of a multi-threaded program below (i.e., an application with multiple independent parts inside of it).

/* thread.c from https://github.com/remzi-arpacidusseau/ostep-code/tree/master/intro */
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.h"

volatile int counter = 0; 
int loops;

void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
    counter++;
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    if (argc != 2) { 
    fprintf(stderr, "usage: threads <loops>\n"); 
    exit(1); 
    } 
    loops = atoi(argv[1]);
    pthread_t p1, p2;
    printf("Initial value : %d\n", counter);
    Pthread_create(&p1, NULL, worker, NULL); 
    Pthread_create(&p2, NULL, worker, NULL);
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("Final value   : %d\n", counter);
    return 0;
}

Although you might not understand this example fully at the moment (and we’ll learn a lot more about it starting in week 3), the basic idea is simple. The main program creates two threads using Pthread_create() 2. You can think of a thread as a function running within the same memory space as other functions, with more than one of them active at a time. In this example, each thread starts running in a routine called worker(), in which it simply increments a counter in a loop for loops number of times.

Below is a transcript of what happens when we run this program with the input value for the variable loops set to 1000. The value of loops determines how many times each of the two workers will increment the shared counter in a loop. When the program is run with the value of loops set to 1000, what do you expect the final value of counter to be?

$ gcc -o thread thread.c -Wall -pthread
$ ./thread 1000
Initial value : 0
Final value : 2000

As you probably guessed, when the two threads are finished, the final value of the counter is 2000, as each thread incremented the counter 1000 times. Indeed, when the input value of loops is set to \(N\), we would expect the final output of the program to be \(2N\). But life is not so simple, as it turns out. Let’s run the same program, but with higher values for loops, and see what happens:

$ ./thread 100000
Initial value : 0
Final value : 143012 // huh??
$ ./thread 100000
Initial value : 0
Final value : 137298 // what the??

In this run, when we gave an input value of 100,000, instead of getting a final value of 200,000, we instead first get 143,012. Then, when we run the program a second time, we not only again get the wrong value, but also a different value than the last time. In fact, if you run the program over and over with high values of loops, you may find that sometimes you even get the right answer! So why is this happening?

As it turns out, the reason for these odd and unusual outcomes relate to how instructions are executed, which is one at a time. Unfortunately, a key part of the program above, where the shared counter is incremented, takes three instructions: one to load the value of the counter from memory into a register, one to increment it, and one to store it back into memory. Because these three instructions do not execute atomically (all at once), strange things can happen. It is this problem of concurrency that we will address in great detail in weeks 3–6.

As this example illustrates, communication between applications or between threads requires more than just allowing them to access shared memory. These accesses must be carefully synchronized to avoid strange and unpredictable behavior.


Now let’s consider memory. The model of physical memory presented by modern machines is very simple. Memory is just an array of bytes; to read memory, one must specify an address to be able to access the data stored there; to write (or update) memory, one must also specify the data to be written to the given address. Let’s take a look at a program that allocates some memory by calling malloc().

/* mem.c from https://github.com/remzi-arpacidusseau/ostep-code/tree/master/intro */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"

int main(int argc, char *argv[]) {
    if (argc != 2) { 
        fprintf(stderr, "usage: mem <value>\n"); 
        exit(1); 
    } 
    int *p; 
    p = malloc(sizeof(int));                                           // a1
    assert(p != NULL);
    printf("(%d) addr pointed to by p: %p\n", (int) getpid(), p);      // a2
    *p = atoi(argv[1]); // assign value to addr stored in p            // a3
    while (1) {
        Spin(1);
        *p = *p + 1;
        printf("(%d) value of p: %d\n", getpid(), *p);                 // a4
    }
    return 0;
}

The program does a couple of things. First, it allocates some memory (line a1). Then, it prints out the address of the memory (a2), and then puts the number from the command line into the first slot of the newly allocated memory (a3). Finally, it loops, delaying for a second and incrementing the value stored at the address held in p. With every print statement, it also prints out what is called the process identifier (the PID) of the running program. This PID is unique per running process.

The output of this program looks like this (Linux only):

$ setarch $(uname --machine) --addr-no-randomize /bin/bash
$ gcc -o mem mem.c -Wall
$ ./mem 5
(18275) addr pointed to by p: 0x555555756260
(18275) value of p: 6
(18275) value of p: 7
(18275) value of p: 8
(18275) value of p: 9
^C

Again, this first result is not too interesting. The newly allocated memory is at address 0x555555756260. As the program runs, it slowly updates the value and prints out the result. Now, we again run multiple instances of this same program to see what happens.

$ ./mem 5 & ./mem 0 &
[1] 18283
[2] 18284
(18283) addr pointed to by p: 0x555555756260
(18284) addr pointed to by p: 0x555555756260
(18283) value of p: 6
(18284) value of p: 1
(18283) value of p: 7
(18284) value of p: 2
(18283) value of p: 8
(18284) value of p: 3
(18283) value of p: 9
(18284) value of p: 4
(18283) value of p: 10
...

We see from the example that each running program has allocated memory at the same address (0x555555756260), and yet each seems to be updating the value at 0x555555756260 independently! It is as if each running program has its own private memory, instead of sharing the same physical memory with other running programs3

4 Reading: Operating System Evaluation

Though precisely defining the idea of an operating system may be challenging, we can clearly state the goals any operating system should aim for. We will use four primary goals to frame our discussion of operating systems throughout this course:

  • Reliability and Availability. Does the operating system do what you want?
  • Security. Can the operating system be corrupted by an attacker?
  • Portability. Is the operating system easy to move to new hardware platforms?
  • Performance. Is the user interface responsive, or does the operating system impose too much overhead?

This reading goes through the aspects and tradeoffs involved in each of these goals.

5 Homework

Your homework is to get set up for the labs we will do this term. You will need to work in a Linux environment. There are two main options for this: work on a CS department server or on your local computer. Working on your local computer has two major advantages: (1) it does not depend on a continuous internet connection and (2) you have more flexibility to use the editor and environment of your choice. I also encourage you to work locally because the CS server risks getting overloaded if everyone is working there simultaneously.

5.1 Setting up a Local Linux Environment

If your computer is Mac or Windows, you will need to set up a separate Linux environment. On Windows 10, this is pretty easy, thanks to the Windows Subsystem for Linux. Follow this guide to set it up. On Mac and earlier versions of Windows, you will need to run a Linux virtual machine (VM). You can find instructions for the official CS VM on the Carleton wiki. The CS VM is quite large (11GB download, ~30 GB expanded, courtesy of lots of useful pre-installed stuff). If that's a problem for you, consider using the much smaller Xubuntu VM from osboxes.org. Like the CS VM, you will want the free VirtualBox to run the VM. If you run into any problems, please post questions to the Moodle forum or Slack.

5.2 Working on a CS Department Server

If you do decide to work remotely on a CS server, this course will use mantis.mathcs.carleton.edu. This is a brand new Linux server running Ubuntu 18 that we should have mostly to ourselves. You can access it via SSH or via JupyterHub at https://mantis.mathcs.carleton.edu:9595/.

5.3 Setting up Your OSV Repository

Our four labs this term will all be working with the OSV operating system, a small, functional OS developed by instructors and researchers at University of Washington. Lab assignments will be distributed and submitted via GitHub and the Git version control system. To learn more about Git, take a look at the Git user's manual or this simple guide. Follow this link to accept the GitHub Classroom assignment: https://classroom.github.com/a/y7YoHCnr. This will create an empty git repository for you within the Carleton-College-CS organization on GitHub. If you don't have a GitHub account associated with your Carleton email, you'll need to create one.

Unfortunately, the way GitHub handles organizations makes accessing your repo slightly more complicated that normal. To interact with it via the command line, I found I needed to use a personal access token, as described here. Make sure to do step 10! I found that connecting my GitHub account to Sourcetree also worked. It appears that for most people, in order to access the OSV repo, I need to add you as a direct collaborator. Once you've joined the GitHub classroom, ping me on Slack and I'll add you.

You need to clone the OSV repository by running the commands below. As mentioned previously you will want to do this on a 64-bit Linux machine. You will be prompted for your GitHub username and password. Use your personal access token as show here. You'll need to set up an SSH key on GitHub.

$ git clone git@github.com:Carleton-College-CS/osv-s20.git
Cloning into 'osv-s20'...
$ cd osv-s20

You will want to work in your own repository instead of working directly in the base repository. From the osv-s20 directory, run the following:

$ git remote rename origin upstream
$ git remote add origin git@github.com:Carleton-College-CS/cs332-s20-osv-YOUR-GIT-USERNAME.git
$ git push -u origin --all

This generates a copy of the main OSV repository in the repository that was created for you at the GitHub Classroom link above. Go to the GitHub page for that repo to make sure.

5.4 Booting OSV

Instead of developing the operating system on a real, physical personal computer (PC), we use a program that faithfully emulates a complete PC: the code you write for the emulator will boot on a real PC too. Using an emulator simplifies debugging; you can, for example, set break points inside of the emulated x86_64 architecture, which is difficult to do with the silicon version of an x86_64 architecture. In OSV, we will use the QEMU Emulator, a modern and fast emulator. If you are working locally you will need to install this with

sudo apt install qemu

or the equivalent if you're using a non-Debian/Ubuntu Linux.

Type make in the OSV root directory to build it. Now you're ready to run QEMU, supplying the file build/fs.img and build/osv.img, created above, as the contents of the emulated PC's virtual hard disk. Those hard disk images contain our boot loader build/arch/x86_64/boot, our kernel build/kernel/kernel.elf and a list of user applications in build/user. Type make qemu to run QEMU with the options required to set the hard disk and direct serial port output to the terminal. Some text should appear in the QEMU window:

E820: physical memory map [mem 0x126000-0x1FFE0000]
 [0x0 - 0x9FC00] usable
 [0x9FC00 - 0xA0000] reserved
 [0xF0000 - 0x100000] reserved
 [0x100000 - 0x1FFE0000] usable
 [0x1FFE0000 - 0x20000000] reserved
 [0xFFFC0000 - 0x100000000] reserved

cpu 0 is up and scheduling 
cpu 1 is up and scheduling 
OSV initialization...Done

$

Press Ctrl-a x to exit the QEMU virtual instance.

If you see warnings about TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5], you can safely ignore them.

Footnotes:

1

Note how we ran four processes at the same time, by using the & symbol. Doing so runs a job in the background in the bash or zsh shells, which means that the user is able to immediately issue their next command, which in this case is another program to run. If you’re using a different shell (e.g., tcsh), it works slightly differently; read documentation online for details.

2

The actual call should be to lower-case pthread_create(); the upper-case version is a wrapper that calls pthread_create() and makes sure that the return code indicates that the call succeeded. See the code for details: https://github.com/remzi-arpacidusseau/ostep-code/tree/master/intro.

3

For this example to work, you need to make sure address-space randomization is disabled; randomization, as it turns out, can be a good defense against certain kinds of security flaws. Hence, the setarch $(uname --machine) --addr-no-randomize /bin/bash command. Read more about it on your own, especially if you want to learn how to break into computer systems via stack-smashing attacks. Not that we would recommend such a thing…