CS 111

Scribe Notes for 1/14/08

by Santoso Wijaya, Derek Kulinski, and Albert Zhu

Modularity & Virtualization

Increasing modularity in last lecture's simple design

Our aim is toward a more flexible API. For example, in the following function prototype

int read (int diskno, int off, char* addr, int nbytes);
has two valuable properties:
  • Virtualization: it pretends to have one behavior (namely 'read') implemented atop another (e.g. inb, insl)
  • Abstraction: a simpler form of virtualization, where the high level is simpler or cleaner

Virtualization choices

0. None

There is always the choice not to implement any virtualization whatsoever. Recall last lecture's sample code.

The advantage to this approach (or lack thereof) is faster speed and/or performance. The downside is that the code is hard to debug and understand. It also does not scale well.

1. Function calls

A common approach, this is implemented, for example, by reserving blocks in memory as "kernel" functions (implementation of a common API) to be used by other programs.

e.g. Consider this simple recursive factorial function:

int fact(int n) {
  if (n==0)
    return 1;
  else
    return n*fact(n-1);
}

This code in C translates into the assembly:

fact:   pushl  %ebp              # save fp
        movl   $1, %eax          # prepare to return 1
        movl   %esp, %ebp        # establish fp
        subl   $8, %esp          # allocating our actv. record
        movl   %ebx, -4(%ebp)    # save ebx register
        movl   8(%ebp), %ebx     # ebx := n
        testl  %ebx, %ebx        # is n zero?
        jne    L5                
L1:     movl   -4(%ebp), %ebx    # restore ebx
        movl   %ebp, %esp        # restore sp
        popl   %ebp
        ret
L5:     leal   -1(%ebx), %eax    # eax := ebx - 1
        movl   %eax, (%esp)
        call   fact
        imull  %ebx, %eax        # result is in eax
        jump   L1

So, to call fact(6) the following instructions are executed:

        pushl $6
        call fact
        addl $4, %esp # pop stack and disregard what's there
                      # result is in %eax
fact()'s activation record:
fact()'s activation record

The above example shows a contract between caller and callee.

This contract will uphold the stack layout meaning that the callee won't do anything to change the arguments that were pushed onto the stack by the caller. When the caller calls the callee, the contract will ensure that the return address will automatically be pushed onto the stack before the callee does anything. When returning to the caller from the callee, the contract also ensures that the return value will be stored in the %eax register.

What can go wrong?

  • Callee attempts to pop arguments off stack to "help out" caller
  • Caller doesn't push the return address onto the stack, just does a jump instead
  • Return value can get lost

These points will all lead to a break of contract between the caller and callee, which will end in chaos!

For example,
Case 1: Untrustworthy Callee

  • Stores a different address into top of stack
  • Stores into caller's activation record
  • Overflows the stack
  • Creates an infinite loop inside the Callee
  • Trashes caller's registers
Case 2: Untrustworthy Caller
  • Changes sp to point into kernel memory
  • Refuses to call kernel, call own copy instead

To sum up, the problem with this function calls approach is that we have a soft modularity - every component is assumed reliable - also called fate sharing.

How does one attain hard modularity, where one untrustworthy component won't crash the system?

2. Hardware support for virtual machines

This is typically done by adding more constraints to what can get called/accessed.

3. Software interpreter

This is a software approach: we emulate the hardware in a program!
The downside of this approach is that the overall system is slower.

4. Client-server approach

A slightly more sophisticated approach is where the server is the OS,
and the client is the application in a client-server architecture.
Some would argue this is the better approach for computers of the future.

5. Other

Above methods aren't perfect, so there are new interesting inventions, though many of them are unsuccessful.


Virtualization via Software Emulation

This is done by having the kernel contain an x86 interpreter (like JVM for example)
Applications are interpreted

A interpreter checks every memory access. It can establish a time limit. It can go run another program if one holds or crashes. Further, the hardware itself needs not be an actual x86 hardware. This leads to a very portable solution. The downside of such approach is, of course, a sacrifice in computing speed.

We need hardware support

Enter virtualizable processor (for each application). The hardware itself is designed so that it will let the kernel take over immediately when the application does a halt or any other privileged instructions. It can also let the kernel take over periodically no matter what to examine running applications (e.g. to catch infinite loops). More importantly, it lets the kernel take control when the application accesses memory that it "shouldn't". This is not limited to RAM but also I/O devices, etc.


The combination of this virtualizable processor and the OS itself lets us support the notion of a process.

  • i.e. a process: a program in execution (in an isolated domain)
    virtualization as a fence
The system, them, can also be thought of as being a virtual computer.

In UNIX, the way one creates a process is by using:

pid_t fork(void);
int execvp(char const *file, char const **argv);
// file is the program to run,
// argv is the command line arguments


Layering

The term refers to the practice of building a higher level abstraction over a lower level one. Layering

To implement, don't use function calls for this; it will result in soft modularity! Instead use protected transfer control - where ordinary apps execute unprivileged instructions at full speed, but when they execute a "bad" instructions, they trap into the OS. In effect, applications cannot fool the kernel.

Applications contact the kernel by executing a "bad" instruction. On x86, the

int [arg byte]
instruction (short for interrupt) causes the processor to enter the kernel state at a location chosen by the kernel.

This is done by a lookup to an interrupt address table:

Interrupt Address Table

How it works:

  • There's a reserved piece of memory - interrupt address table - to handle control transfer
  • The instruction pushes information into the stack (enough to recover the state of the currently running process), and then enters "privileged mode" (there are four privileged modes in x86 architecture, each more privileged than the next culminating in level 0 - kernel - that can execute all privileged instructions without restrictions) privileged modes

The interrupt instructions are also called system calls. These are like function calls, but with protected transfer of control. They are slower, and can be more of a hassle (applications can't do some "reasonable" things as a result of forced hard modularity).


Question to ponder:
How would one design a robust system despite kernel bugs?