In this lab you will implement the basic kernel facilities required to get a protected user-mode environment (i.e., "process") running. You will enhance the JOS kernel to set up the data structures to keep track of user environments, create a single user environment, load a program image into it, and start it running.
Note: In this lab, the terms environment and process are interchangeable—both refer to an abstraction that allows you to run a program. We introduce the term "environment" instead of the traditional term "process" in order to stress the point that JOS environments and UNIX processes provide different interfaces, and do not provide the same semantics.
Create a local branch called lab3 based on our lab3 branch, origin/lab3, and then fetch the latest version from the course repository:
$ cd ~/cs134/lab $ git checkout --track origin/lab3 Branch lab3 set up to track remote branch refs/remotes/origin/lab3. Switched to a new branch "lab3" $ git pull upstream lab3 # Pulls any changes I have made in the upstream repository $
You will now need to merge the changes you made in your lab2 branch into the lab3 branch, as follows:
$ git merge lab2 Merge made by the recursive strategy. ... $
In some cases, Git may not be able to figure out how to merge your changes with the new lab assignment (e.g. if you modified some of the code that is changed in the second lab assignment). In that case, the git merge command will tell you which files are conflicted, and you should first resolve the conflict (by editing the relevant files) and then commit the resulting files with git commit -a.
Important note. If your Pull Request for lab2 has finished being reviewed, then you know that lab2 is complete, and you will never need to merge from lab2 again. However, if it is is still being reviewed, then there may be changes required before the review is complete. Those changes will need to be merged into lab3-no-code, which you can do after the Pull Request is complete, by another call to git merge lab2 from lab3-no-code. Then, you would do a git merge lab3-no-code from lab3.You should merge into both labs so that the Pull Request for lab3 does not include the changes from lab2.
At this point, Lab 3 is ready to go. Before making any code changes, do the following:
$ git branch lab3-no-code # creates a branch prior to adding any Lab 3 code $ git push -u origin lab3-no-code # pushes the new branch to the origin
Lab 3 contains a number of new source files, which you should browse:
inc/ | env.h | Public definitions for user-mode environments |
trap.h | Public definitions for trap handling | |
syscall.h | Public definitions for system calls from user environments to the kernel | |
lib.h | Public definitions for the user-mode support library | |
kern/ | env.h | Kernel-private definitions for user-mode environments |
env.c | Kernel code implementing user-mode environments | |
trap.h | Kernel-private trap handling definitions | |
trap.c | Trap handling code | |
trapentry.S | Assembly-language trap handler entry-points | |
syscall.h | Kernel-private definitions for system call handling | |
syscall.c | System call implementation code | |
lib/ | Makefrag | Makefile fragment to build user-mode library, obj/lib/libjos.a |
entry.S | Assembly-language entry-point for user environments | |
libmain.c | User-mode library setup code called from entry.S | |
syscall.c | User-mode system call stub functions | |
console.c | User-mode implementations of putchar and getchar, providing console I/O | |
exit.c | User-mode implementation of exit | |
panic.c | User-mode implementation of panic | |
user/ | * | Various test programs to check kernel lab 3 code |
In addition, a number of the source files we handed out for lab2 are modified in lab3. To see the differences, you can type:
$ git diff lab2
You may also want to take another look at the lab tools guide, as it includes information on debugging user code that becomes relevant in this lab.
In this lab and subsequent labs, do all of the regular exercises described in the lab. You can also do challenge problems. (Some challenge problems are more challenging than others, of course!) Additionally, write up brief answers to any questions posed in the lab and a short (e.g., one or two paragraph) description of what you did to solve each chosen challenge problem. Place the write-up in a file called answers-lab3.txt in the top level of your lab directory before submitting your work. Do not forget to add that file to git.
Passing all the make grade tests does not mean your code is perfect. It may have subtle bugs that will only be tickled by future labs. In particular, all your kernel code is running in the same address space with no protection. If you get weird crashes that don't seem to be explainable by a bug in the crashing code, it's likely that they're due to a bug somewhere else that is modifying memory used by the crashing code. GDB watchpoints are often a good way to debug such problems.
In this lab you may find GCC's inline assembly language feature useful, although it is also possible to complete the lab without using it. At the very least, you will need to be able to understand the fragments of inline assembly language ("asm" statements) that already exist in the source code we gave you. You can find several sources of information on GCC inline assembly language on the class reference materials page.
The new include file inc/env.h
contains basic definitions for user environments in JOS.
Read it now.
The kernel uses the Env
data structure
to keep track of each user environment.
In this lab you will initially create just one environment,
but you will need to design the JOS kernel
to support multiple environments;
lab 4 will take advantage of this feature
by allowing a user environment to fork
other environments.
As you can see in kern/env.c, the kernel maintains three main global variables pertaining to environments:
struct Env *envs = NULL; // All environments struct Env *curenv = NULL; // The current env static struct Env *env_free_list; // Free environment list
Once JOS gets up and running,
the envs
pointer points to an array of Env
structures
representing all the environments in the system.
In our design,
the JOS kernel will support a maximum of NENV
simultaneously active environments,
although there will typically be far fewer running environments
at any given time.
(NENV
is a constant #define
'd in inc/env.h.)
Once it is allocated,
the envs
array will contain
a single instance of the Env
data structure
for each of the NENV
possible environments.
The JOS kernel keeps all of the inactive Env
structures
on the env_free_list
.
This design allows easy allocation and
deallocation of environments,
as they merely have to be added to or removed from the free list.
The kernel uses the curenv
symbol
to keep track of the currently executing environment at any given time.
During boot up, before the first environment is run,
curenv
is initially set to NULL
.
The Env
structure
is defined in inc/env.h as follows
(although more fields will be added in future labs):
struct Env { struct Trapframe env_tf; // Saved registers struct Env *env_link; // Next free Env envid_t env_id; // Unique environment identifier envid_t env_parent_id; // env_id of this env's parent enum EnvType env_type; // Indicates special system environments unsigned env_status; // Status of the environment uint32_t env_runs; // Number of times environment has run // Address space pde_t *env_pgdir; // Kernel virtual address of page dir };
Here's what the Env
fields are for:
Env
on the
env_free_list
. env_free_list
points
to the first free environment on the list.Env
structure
(i.e., using this particular slot in the envs
array).
After a user environment terminates,
the kernel may re-allocate
the same Env
structure to a different environment -
but the new environment
will have a different env_id
from the old one
even though the new environment
is re-using the same slot in the envs
array.env_id
of the environment that created this environment.
In this way the environments can form a “family tree,”
which will be useful for making security decisions
about which environments are allowed to do what to whom.ENV_TYPE_USER
. We'll introduce
a few more types for special system service environments in later labs.
ENV_FREE
:Env
structure is inactive,
and therefore on the env_free_list
.ENV_RUNNABLE
:Env
structure
represents an environment that is waiting to run on the
processor.ENV_RUNNING
:Env
structure
represents the currently running environment.ENV_NOT_RUNNABLE
:Env
structure
represents a currently active environment,
but it is not currently ready to run:
for example, because it is waiting
for an interprocess communication (IPC)
from another environment.ENV_DYING
:Env
structure
represents a zombie environment. A zombie environment will be
freed the next time it traps to the kernel. We will not use
this flag until Lab 5.
Like a Unix process, a JOS environment couples the concepts of
"thread" and "address space". The thread is defined primarily by the
saved registers (the env_tf
field), and the address space
is defined by the page directory and page tables pointed to by
env_pgdir
. To run an
environment, the kernel must set up the CPU with both the saved
registers and the appropriate address space.
Our struct Env
is analogous to struct proc
in xv6. Both structures hold the environment's (i.e., process's)
user-mode register state in a Trapframe
structure. In JOS,
individual environments do not have their own kernel stacks as
processes do in xv6. There can be only one JOS environment active in
the kernel at a time, so JOS needs only a
single kernel stack.
In lab 2,
you allocated memory in mem_init()
for the pages[]
array,
which is a table the kernel uses to keep track of
which pages are free and which are not.
You will now need to modify mem_init()
further
to allocate a similar array of Env
structures,
called envs
.
Exercise 1.
Modify mem_init()
in kern/pmap.c
to allocate and map the envs
array.
This array consists of
exactly NENV
instances of the Env
structure allocated much like how you allocated the
pages
array.
Also like the pages
array, the memory backing
envs
should also be mapped user read-only at
UENVS
(defined in inc/memlayout.h) so
user processes can read from this array.
You should run your code and make sure
check_kern_pgdir()
succeeds.
You will now write the code in kern/env.c necessary to run a user environment. Because we do not yet have a filesystem, we will set up the kernel to load a static binary image that is embedded within the kernel itself. JOS embeds this binary in the kernel as a ELF executable image.
The Lab 3 GNUmakefile generates a number of binary images in the obj/user/ directory. If you look at kern/Makefrag, you will notice some magic that "links" these binaries directly into the kernel executable as if they were .o files. The -b binary option on the linker command line causes these files to be linked in as "raw" uninterpreted binary files rather than as regular .o files produced by the compiler. (As far as the linker is concerned, these files do not have to be ELF images at all—they could be anything, such as text files or pictures!) If you look at obj/kern/kernel.sym after building the kernel, you will notice that the linker has "magically" produced a number of funny symbols with obscure names like _binary_obj_user_hello_start, _binary_obj_user_hello_end, and _binary_obj_user_hello_size. The linker generates these symbol names by mangling the file names of the binary files; the symbols provide the regular kernel code with a way to reference the embedded binary files.
In i386_init()
in kern/init.c you'll see code to run
one of these binary images in an environment. However, the critical
functions to set up user environments are not complete; you will need
to fill them in.
Exercise 2. In the file env.c, finish coding the following functions:
env_init()
Env
structures
in the envs
array and
add them to the env_free_list
.
Also calls env_init_percpu
, which
configures the segmentation hardware with
separate segments for privilege level 0 (kernel) and
privilege level 3 (user).env_setup_vm()
region_alloc()
load_icode()
env_create()
env_alloc
and call load_icode
to load an ELF binary into it.env_run()
As you write these functions,
you might find the new cprintf verb %e
useful—it prints a description corresponding to an error code.
For example,
r = -E_NO_MEM; panic("env_alloc: %e", r);
will panic with the message "env_alloc: out of memory".
Below is a call graph of the code up to the point where the user code is invoked. Make sure you understand the purpose of each step.
start
(kern/entry.S
)i386_init
(kern/init.c
)
cons_init
mem_init
env_init
trap_init
(still incomplete at this point)env_create
env_run
env_pop_tf
Once you are done you should compile your kernel and run it under QEMU.
If all goes well, your system should enter user space and execute the
hello binary until it makes a system call with the
int
instruction. At that point there will be trouble, since
JOS has not set up the hardware to allow any kind of transition
from user space into the kernel.
When the CPU discovers that it is not set up to handle this system
call interrupt, it will generate a general protection exception, find
that it can't handle that, generate a double fault exception, find
that it can't handle that either, and finally give up with what's
known as a "triple fault". Usually, you would then see the CPU reset
and the system reboot. While this is important for legacy
applications (see
this blog post for an explanation of why), it's a pain for kernel
development, so with the CS 134-patched QEMU you'll instead see a
register dump and a "Triple fault." message.
We'll address this problem shortly, but for now we can use the
debugger to check that we're entering user mode. Use make
qemu-gdb and set a GDB breakpoint at env_pop_tf
,
which should be the last function you hit before actually entering user mode.
Single step through this function using si;
the processor should enter user mode after the iret
instruction.
You should then see the first instruction
in the user environment's executable,
which is the cmpl
instruction at the label start
in lib/entry.S.
Now use b *0x... to set a breakpoint at the
int $0x30
in sys_cputs()
in hello
(see obj/user/hello.asm for the user-space address).
This int
is the system call to display a character to
the console.
If you cannot execute as far as the int
,
then something is wrong with your address space setup
or program loading code;
go back and fix it before continuing.
At this point,
the first int $0x30
system call instruction in user space
is a dead end:
once the processor gets into user mode,
there is no way to get back out.
You will now need to implement
basic exception and system call handling,
so that it is possible for the kernel to recover control of the processor
from user-mode code.
The first thing you should do
is thoroughly familiarize yourself with
the x86 interrupt and exception mechanism.
Exercise 3. Read Chapter 5 of the IA-32 Developer's Manual if you haven't already.
In this lab we generally follow Intel's terminology for interrupts, exceptions, and the like. However, terms such as exception, trap, interrupt, fault and abort have no standard meaning across architectures or operating systems, and are often used without regard to the subtle distinctions between them on a particular architecture such as the x86. When you see these terms outside of this lab, the meanings might be slightly different.
Exceptions and interrupts are both "protected control transfers," which cause the processor to switch from user to kernel mode (CPL=0) without giving the user-mode code any opportunity to interfere with the functioning of the kernel or other environments. In Intel's terminology, an interrupt is a protected control transfer that is caused by an asynchronous event usually external to the processor, such as notification of external device I/O activity. An exception, in contrast, is a protected control transfer caused synchronously by the currently running code, for example due to a divide by zero or an invalid memory access.
In order to ensure that these protected control transfers are actually protected, the processor's interrupt/exception mechanism is designed so that the code currently running when the interrupt or exception occurs does not get to choose arbitrarily where the kernel is entered or how. Instead, the processor ensures that the kernel can be entered only under carefully controlled conditions. On the x86, two mechanisms work together to provide this protection:
The Interrupt Descriptor Table. The processor ensures that interrupts and exceptions can only cause the kernel to be entered at a few specific, well-defined entry-points determined by the kernel itself, and not by the code running when the interrupt or exception is taken.
The x86 allows up to 256 different interrupt or exception entry points into the kernel, each with a different interrupt vector. A vector is a number between 0 and 255. An interrupt's vector is determined by the source of the interrupt: different devices, error conditions, and application requests to the kernel generate interrupts with different vectors. The CPU uses the vector as an index into the processor's interrupt descriptor table (IDT), which the kernel sets up in kernel-private memory, much like the GDT. From the appropriate entry in this table the processor loads:
The Task State Segment. The processor needs a place to save the old processor state before the interrupt or exception occurred, such as the original values of EIP and CS before the processor invoked the exception handler, so that the exception handler can later restore that old state and resume the interrupted code from where it left off. But this save area for the old processor state must in turn be protected from unprivileged user-mode code; otherwise buggy or malicious user code could compromise the kernel.
For this reason, when an x86 processor takes an interrupt or trap that causes a privilege level change from user to kernel mode, it also switches to a stack in the kernel's memory. A structure called the task state segment (TSS) specifies the segment selector and address where this stack lives. The processor pushes (on this new stack) SS, ESP, EFLAGS, CS, EIP, and an optional error code. Then it loads the CS and EIP from the interrupt descriptor, and sets the ESP and SS to refer to the new stack.
Although the TSS is large and can potentially serve a variety of purposes, JOS only uses it to define the kernel stack that the processor should switch to when it transfers from user to kernel mode. Since "kernel mode" in JOS is privilege level 0 on the x86, the processor uses the ESP0 and SS0 fields of the TSS to define the kernel stack when entering kernel mode. JOS doesn't use any other TSS fields.
All of the synchronous exceptions
that the x86 processor can generate internally
use interrupt vectors between 0 and 31,
and therefore map to IDT entries 0-31.
For example,
a page fault always causes an exception through vector 14.
Interrupt vectors greater than 31 are only used by
software interrupts,
which can be generated by the int
instruction, or
asynchronous hardware interrupts,
caused by external devices when they need attention.
In this section we will extend JOS to handle the internally generated x86 exceptions in vectors 0-31. In Lab 4 we will make JOS handle software interrupt vector 48 (0x30), which JOS (fairly arbitrarily) uses as its system call interrupt vector. In Lab 5 we will extend JOS to handle externally generated hardware interrupts such as the clock interrupt.
Let's put these pieces together and trace through an example. Let's say the processor is executing code in a user environment and encounters a divide instruction that attempts to divide by zero.
GD_KD
and KSTACKTOP
, respectively.KSTACKTOP
:
+--------------------+ KSTACKTOP | 0x00000 | old SS | " - 4 | old ESP | " - 8 | old EFLAGS | " - 12 | 0x00000 | old CS | " - 16 | old EIP | " - 20 <---- ESP +--------------------+
For certain types of x86 exceptions, in addition to the "standard" five words above, the processor pushes onto the stack another word containing an error code. The page fault exception, number 14, is an important example. See the 80386 manual to determine for which exception numbers the processor pushes an error code, and what the error code means in that case. When the processor pushes an error code, the stack would look as follows at the beginning of the exception handler when coming in from user mode:
+--------------------+ KSTACKTOP | 0x00000 | old SS | " - 4 | old ESP | " - 8 | old EFLAGS | " - 12 | 0x00000 | old CS | " - 16 | old EIP | " - 20 | error code | " - 24 <---- ESP +--------------------+
The processor can take exceptions and interrupts both from kernel and user mode. It is only when entering the kernel from user mode, however, that the x86 processor automatically switches stacks before pushing its old register state onto the stack and invoking the appropriate exception handler through the IDT. If the processor is already in kernel mode when the interrupt or exception occurs (the low 2 bits of the CS register are already zero), then the CPU just pushes more values on the same kernel stack. In this way, the kernel can gracefully handle nested exceptions caused by code within the kernel itself. This capability is an important tool in implementing protection, as we will see later in the section on system calls.
If the processor is already in kernel mode and takes a nested exception, since it does not need to switch stacks, it does not save the old SS or ESP registers. For exception types that do not push an error code, the kernel stack therefore looks like the following on entry to the exception handler:
+--------------------+ <---- old ESP | old EFLAGS | " - 4 | 0x00000 | old CS | " - 8 | old EIP | " - 12 +--------------------+
For exception types that push an error code, the processor pushes the error code immediately after the old EIP, as before.
There is one important caveat to the processor's nested exception capability. If the processor takes an exception while already in kernel mode, and cannot push its old state onto the kernel stack for any reason such as lack of stack space, then there is nothing the processor can do to recover, so it simply resets itself. Needless to say, the kernel should be designed so that this can't happen.
You should now have the basic information you need in order to set up the IDT and handle exceptions in JOS. For now, you will set up the IDT to handle interrupt vectors 0-31 (the processor exceptions). We'll handle system call interrupts later in this lab and add interrupts 32-47 (the device IRQs) in a later lab.
The header files inc/trap.h and kern/trap.h contain important definitions related to interrupts and exceptions that you will need to become familiar with. The file kern/trap.h contains definitions that are strictly private to the kernel, while inc/trap.h contains definitions that may also be useful to user-level programs and libraries.
Note: Some of the exceptions in the range 0-31 are defined by Intel to be reserved. Since they will never be generated by the processor, it doesn't really matter how you handle them. Do whatever you think is cleanest.
The overall flow of control that you should achieve is depicted below:
IDT trapentry.S trap.c +----------------+ | &handler1 |---------> handler1: trap (struct Trapframe *tf) | | // do stuff { | | call trap // handle the exception/interrupt | | // ... } +----------------+ | &handler2 |--------> handler2: | | // do stuff | | call trap | | // ... +----------------+ . . . +----------------+ | &handlerX |--------> handlerX: | | // do stuff | | call trap | | // ... +----------------+
Each exception or interrupt should have
its own handler in trapentry.S
and trap_init()
should initialize the IDT with the addresses
of these handlers.
Each of the handlers should build a struct Trapframe
(see inc/trap.h) on the stack and call
trap()
(in trap.c)
with a pointer to the Trapframe.
trap()
then handles the
exception/interrupt or dispatches to a specific
handler function.
Exercise 4.
Edit trapentry.S and trap.c and
implement the features described above. The macros
TRAPHANDLER
and TRAPHANDLER_NOEC
in
trapentry.S should help you, as well as the T_*
defines in inc/trap.h. You will need to add an
entry point in trapentry.S (using those macros)
for each trap defined in inc/trap.h, and
you'll have to provide _alltraps
which the
TRAPHANDLER
macros refer to. You will
also need to modify trap_init()
to initialize the
idt
to point to each of these entry points
defined in trapentry.S; the SETGATE
macro will be helpful here.
Your _alltraps
should:
GD_KD
into %ds and %espushl %esp
to pass a pointer to the Trapframe as an argument to trap()call trap
(can trap
ever return?)
Consider using the pushal
instruction; it fits nicely with the layout of the struct
Trapframe
.
Test your trap handling code using some of the test programs in the user directory that cause exceptions before making any system calls, such as user/divzero. You should be able to get make grade to succeed on the divzero, softint, and badsegment tests at this point.
Challenge!
You probably have a lot of very similar code
right now, between the lists of TRAPHANDLER
in
trapentry.S and their installations in
trap.c. Clean this up. Change the macros in
trapentry.S to automatically generate a table for
trap.c to use. Note that you can switch between
laying down code and data in the assembler by using the
directives .text
and .data
.
Questions
Answer the following questions in your answers-lab3.txt:
int $14
.
Why should this produce interrupt vector 13?
What happens if the kernel actually allows softint's
int $14
instruction to invoke the kernel's page fault handler
(which is interrupt vector 14)?This completes the lab. In the lab directory, commit your changes with git commit and type make handin to get instructions for submitting your code.
See our page on GitHub and Pull Requests for detailed information on pull requests and submitting your code.