[Kernel, courtesy IowaFarmer.com CornCam]

CS 235 Advanced Operating Systems, Fall 2010

Lab 2: Memory Management and Exceptions

Due 11:59pm Friday, October 22

Introduction

In this lab, you'll write the memory management and initial exception handling code for your operating system.

The first component of memory management is virtual memory, where we set up the PC's Memory Management Unit (MMU) hardware to map the virtual addresses used by software into the physical addresses that map directly to memory chips. You will modify JOS to set up virtual memory mappings according to a specification we provide. In particular, you'll build a page table data structure to match our specification.

The second component is managing the physical memory of the computer so that the kernel can allocate and free physical memory as needed. The x86 divides physical memory into 4096-byte regions called pages. Your task will be to maintain data structures that record which physical pages are free and which are allocated, and how many processes are sharing each allocated page. You will also write the routines to allocate and free pages of memory.

Finally, you'll write initial exception-handling code for JOS, so that it can take an interrupt while in kernel mode.

Getting started

In this and future labs you will progressively build on this same kernel. With each new lab we will update our source to contain additional files and possibly some changes to existing files; you'll need to merge your changes from the previous lab into the new source tree.

To fetch the new source, use Git to commit your lab 1 and save that code in a branch called lab1. Then fetch the latest version of the course repository, and update your local branch based on our lab2 branch, origin/lab2:

% git status
...               # Look over the output of this command.
                  # If you added new files to your source, add them
                  # using "git add".
% git commit -am 'my solution to lab1'
Created commit 254dac5: my solution to lab1
 3 files changed, 31 insertions(+), 6 deletions(-)
% git branch lab1
                  # This creates the lab1 branch.
% git fetch
                  # This fetches our new code.
% git checkout -b lab2 origin/lab2
Branch lab2 set up to track remote branch refs/remotes/origin/lab2.
Switched to a new branch "lab2"

The git checkout -b command shown above actually does two things: it first creates a local branch lab2 that is based on the origin/lab2 branch provided by the course staff, and second, it changes the contents of your lab directory to reflect the files stored on the lab2 branch. Git allows switching between existing branches using git checkout branch-name, so you can recover your Lab 1 code with git checkout lab1. But you should commit any outstanding changes on one branch before switching to a different one.

You will now need to merge the changes you made in your lab1 branch into the lab2 branch, as follows:

% git merge lab1
Merge made by recursive.
 kern/kdebug.c  |   11 +++++++++--
 kern/monitor.c |   19 +++++++++++++++++++
 lib/printfmt.c |    7 +++----
 3 files changed, 31 insertions(+), 6 deletions(-)

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.

You can download the tarball if you'd like, but we recommend using Git.

You should browse through these source files for Lab 2, most of which are new.

  • inc/memlayout.h
  • kern/pmap.c
  • kern/pmap.h
  • kern/kclock.h
  • kern/kclock.c
  • inc/trap.h
  • kern/trap.h
  • kern/trap.c
  • kern/trapentry.S

memlayout.h describes the layout of the virtual address space that you must implement by modifying pmap.c. pmap.h defines the Page structure that you'll use to keep track of which pages of physical memory are free. kclock.c and kclock.h manipulate the PC's battery-backed clock and CMOS RAM hardware, in which the BIOS records the amount of physical memory the PC contains, among other things. The code in pmap.c needs to read this device hardware in order to figure out how much physical memory there is, but that part of the code is done for you: you do not need to know the details of how the CMOS hardware works. The last four files are used to set up the processor's interrupt descriptor table and handle interrupts and exceptions.

Lab Requirements

In this lab, you'll need to do all of the regular exercises described in the lab and at least one challenge problem. Some challenge problems are more challenging than others, of course! And feel free to design your own challenge problem; just check with me first.

Additionally, write up brief answers to the 8 explicitly-marked Questions posed in the lab and a short (one or two paragraph) description of what you did to solve your chosen challenge problem. If you implement more than one challenge problem, you only need to describe one of them in the write-up, though of course you are welcome to do more. Place the write-up in a file called answers.txt (plain text) or answers.html (HTML format) in the top level of your lab2 directory before handing in your work.

Hand-In Procedure

When you are ready to hand in your lab code and write-up, run make tarball in your jos directory. This will create a file called lab2-yourusername.tar.gz, which you should submit via CourseWeb at 11:59pm on Friday, October 22. If you have problems with CourseWeb, you may also email me the file.

As before, we will be grading your solutions with a grading program. You can run make grade to test your kernel with the grading program. You may change any of the kernel source and header files you need to in order to complete the lab, but we check to make sure you aren't changing or otherwise subverting the grading code.

Part 1: Virtual Memory

Before doing anything else, you will need to familiarize yourself with the x86's protected-mode memory management architecture: namely segmentation and page translation.

Exercise 1. Read chapters 5 and 6 of the Intel 80386 Reference Manual, if you haven't done so already. Although JOS relies most heavily on page translation, you will also need a basic understanding of how segmentation works in protected mode to understand what's going on.

In x86 terminology, a virtual address is a "segment:offset"-style address before segment translation is performed; a linear address is what you get after segmentation but before page translation; and a physical address is what you finally get after both segmentation and page translation. Be sure you understand the difference between these three types or "levels" of addresses! (For instance, what type of addresses are stored in page directory and page table entries, virtual or physical?)

The JOS kernel tries to use consistent type names for different kinds of address. In particular, the type uintptr_t represents virtual addresses, and physaddr_t represents physical addresses. Of course, both these types are really just synonyms for 32-bit integers (uint32_t), so the compiler won't stop you from assigning one type to another! Every pointer value in JOS should be a virtual address (once paging is set up), since only virtual addresses can be dereferenced. The kernel runs in protected mode too! To summarize:

C typeAddress type
T*  Virtual
uintptr_t  Virtual
physaddr_t  Physical

Question:
  1. Assuming that the following code won't fail to compile or crash the JOS kernel, should mystery_t be uintptr_t or physaddr_t?
    	mystery_t x;
    	char* value = return_a_pointer();
    	*value = 10;
    	x = (mystery_t) value;

The Physical Page Allocator

The operating system must keep track of which parts of physical RAM are free and which are currently in use. JOS manages the PC's physical memory with page granularity so that it can use the MMU to map and protect each piece of allocated memory.

You'll now write the physical page allocator. The kernel maintains an array of struct Page objects, one per physical page. Those Page objects whose physical pages are available for allocation are kept on a linked list, page_free_list. You need to write the physical page allocator before you can write the rest of the virtual memory implementation, because your page table management code will need to allocate physical memory in which to store page tables.

Exercise 2. Implement the following functions in kern/pmap.c.
	boot_alloc()
	page_init()
	page_alloc()
	page_free()
Then complete the implementation of the mem_init() function up to the call to check_page_alloc(). These functions initialize the physical memory allocator.

Once you have done this, run the code by booting JOS. The function call to check_page_alloc() will check your allocator and report any problems it finds. Do not continue until you pass this check. Your code should also pass the Physical page allocator test when you run make grade. You may find it helpful to add your own assert()s to verify that your own assumptions are, in fact, correct.

Page Table Management

Now you'll write a set of routines to manage page tables: to insert and remove linear-to-physical mappings, and to create page table pages when needed.

In future labs you will often map the same physical page at multiple virtual addresses (or in the address spaces of multiple environments), so you will keep a count of the number of times each physical page is mapped in the corresponding Page's pp_ref. The count should be equal to the number of pointers to the page in the page table(s) below UTOP (the mappings above UTOP are mostly just set up at boot time by the kernel and are not tracked by the reference-counting system). When the count goes to zero, the physical page can be freed.

Exercise 3. In the file kern/pmap.c, you must implement code for the four functions listed below: You may find it useful to read inc/memlayout.h and kern/pmap.h.
	pgdir_walk()
	page_lookup()
	page_remove()
	page_insert()

The function check_page(), called from mem_init(), tests these functions. You must get check_page() to run successfully before proceeding. Your code should also pass the Page management test when you run make grade.

The JOS Kernel Virtual Address Space

Now, mem_init() will set up the kernel's virtual address space -- the part above UTOP. The actual layout is diagrammed in inc/memlayout.h.

In Part 3 of Lab 1 we noted that the boot loader sets up the x86 segmentation hardware so that the kernel appears to run at its link address of 0xf0100000, even though it is actually loaded in physical memory just above the ROM BIOS at 0x00100000. In other words, the kernel's virtual starting address at this point is 0xf0100000, but its linear and physical starting addresses are both 0x00100000. The kernel's linear and physical addresses are the same because we have not yet initialized or enabled page translation.

In the virtual memory layout you are going to set up for JOS, we will stop using the x86 segmentation hardware for anything interesting, and instead start using page translation to accomplish everything we've already done with segmentation and much more. That is, after you finish this lab and the JOS kernel successfully enables paging, linear addresses will be the same as (the offset portion of) the kernel's virtual addresses, rather than being the same as physical addresses as they are when the boot loader first enters the kernel.

In JOS, we divide the processor's 32-bit linear address space into two parts. User environments (processes), which we will begin loading and running in lab 3, will have control over the layout and contents of the lower part, while the kernel always maintains complete control over the upper part. The dividing line is defined somewhat arbitrarily by the symbol ULIM in inc/memlayout.h, reserving approximately 256MB of linear (and therefore virtual) address space for the kernel. This explains why we needed to give the kernel such a high link address in lab 1: otherwise there would not be enough room in the kernel's linear address space to map in a user environment below it at the same time. The JOS kernel also maintains constant complete control over the contents of memory by mapping all of physical memory to its portion of the address space. To change a byte of physical memory, all it needs to do is find a kernel virtual pointer for this address (using the KADDR macro), and change the value stored in that pointer.

Since the kernel and user environment will effectively co-exist in each environment's address space, we will have to use permission bits in our x86 page tables to prevent user code from accessing the kernel's memory: i.e., to enforce isolation. The user environment will have no permission to any of the memory above ULIM, while the kernel will be able to read and write this memory. For the address range [UTOP,ULIM), both the kernel and the user environment have the same permission: they can read but not write this address range. This range of addresses is used to expose certain kernel data structures read-only to the user environment. Lastly, the address space below UTOP is for the user environment to use; the user environment will set permissions for accessing this memory.

Question:
  1. What is the maximum amount of physical memory that this operating system can support? Why?

It's now time to set up the kernel portion of the address space.

Exercise 4. In the file kern/pmap.c, you must implement code for the page_map_segment function. Then, follow the comments to complete the code in mem_init.

The function check_kern_pgdir(), called from mem_init(), tests your work. You must get check_kern_pgdir() to run successfully before proceeding. Your code should also pass the Kernel page directory test when you run make grade.

Questions:

  1. What entries (rows) in the page directory have been filled in at this point? What addresses do they map and where do they point? In other words, fill out this table as much as possible:
    Entry Base virtual address Logically points to
    1023 0xFFC00000 Page table for top 4MB of physical memory
    10220xFF800000?
    .........
    20x00800000?
    10x00400000?
    00x00000000[see next question?]
  2. In mem_init(), after check_kern_pgdir, we map the first entry of the page directory to the page table of the first four MB of RAM, but delete this mapping at the end of the function. Why is this necessary? What would happen if it were omitted? Does this actually limit our kernel to be 4MB? What must be true if our kernel were larger than 4MB?

  3. On the x86, we place the kernel and user environment in the same address space. What specific mechanism (i.e., what register, memory address, or bit thereof) is used to protect the kernel's memory against a malicious user process?

  4. How much space overhead is there for managing memory, if we actually had the maximum amount of physical memory? How is this overhead broken down? Overhead includes the Page structures and the kernel's page directory.

Challenge! As Question 6 shows, we consumed many physical pages to hold the page tables for the KERNBASE mapping. Do a more space-efficient job using the PTE_PS ("Page Size") bit in the page directory entries. This bit was not supported in the original 80386, but is supported on more recent x86 processors. You will therefore have to refer to Volume 3 of the current Intel manuals. Make sure you design the kernel to use this optimization only on processors that support it!

Challenge! Extend the JOS kernel monitor with commands to:
  • Display in a useful and easy-to-read format all of the physical page mappings (or lack thereof) that apply to a particular range of virtual/linear addresses in the currently active address space. For example, you might enter 'showmappings 0x3000 0x5000' to display the physical page mappings and corresponding permission bits that apply to the pages at virtual addresses 0x3000, 0x4000, and 0x5000.
  • Explicitly set, clear, or change the permissions of any mapping in the current address space.
  • Dump the contents of a range of memory given either a virtual or physical address range. Be sure the dump code behaves correctly when the range extends across page boundaries!
  • Do anything else that you think might be useful later for debugging the kernel. (There's a good chance it will be!)

Address Space Layout Alternatives

Many other address space layouts are possible besides the one we chose for JOS; all of this is up to the operating system. It is possible, for example, to map the kernel at low linear addresses while leaving the upper part of the linear address space for user processes. x86 kernels generally do not take this approach, however, because one of the x86's backward-compatibility modes, known as virtual 8086 mode, is "hard-wired" in the processor to use the bottom part of the linear address space, and thus cannot be used at all if the kernel is mapped there.

It is even possible, though much more difficult, to design the kernel so as not to have to reserve any fixed portion of the processor's linear or virtual address space for itself, but instead effectively to allow allow user-level processes unrestricted use of the entire 4GB of virtual address space -- while still fully protecting the kernel from these processes and protecting different processes from each other!

Challenge! Write up an outline of how a kernel could be designed to allow user environments unrestricted use of the full 4GB virtual and linear address space. Hint: the technique is sometimes known as "follow the bouncing kernel" -- and one of the x86's "legacy" memory features may come in handy! In your design, be sure to address exactly what has to happen when the processor transitions between kernel and user modes, and how the kernel would accomplish such transitions. Also describe how the kernel would access physical memory and I/O devices in this scheme, and how the kernel would access a user environment's virtual address space during system calls and the like. Finally, think about and describe the advantages and disadvantages of such a scheme in terms of flexibility, performance, kernel complexity, and other factors you can think of.

Challenge! Since our JOS kernel's memory management system only allocates and frees memory on page granularity, we do not have anything comparable to a general-purpose malloc/free facility that we can use within the kernel. This could be a problem if we want to support certain types of I/O devices that require physically contiguous buffers larger than 4KB in size, or if we want user-level environments, and not just the kernel, to be able to allocate and map 4MB superpages for maximum processor efficiency. (See the earlier challenge problem about PTE_PS.)

Generalize the kernel's memory allocation system to support pages of a variety of power-of-two allocation unit sizes from 4KB up to some reasonable maximum of your choice. Be sure you have some way to divide larger allocation units into smaller ones on demand, and to coalesce multiple small allocation units back into larger units when possible. Think about the issues that might arise in such a system.

Challenge! Extend the JOS kernel monitor with commands to allocate and free pages explicitly, and display whether or not any given page of physical memory is currently allocated. For example:
	K> alloc_page
		0x13000
	K> page_status 0x13000
		allocated
	K> free_page 0x13000
	K> page_status 0x13000
		free
	
Think of other commands or extensions to these commands that may be useful for debugging, and add them.

Part 3: Handling Interrupts and Exceptions

In this part of the lab, you'll add initial support for exception handling to your JOS kernel. This includes processor exceptions, such as divide-by-zero errors; hardware interrupts; and system calls, where user-level programs transfer control to the kernel. (We don't have user-level programs yet, but exceptions like page faults and divide-by-zero can happen in the kernel too.) All of these are types of protected control transfer that, among other things, enable the processor to switch from user to kernel mode cleanly without giving the user-mode code any opportunity to interfere with the functioning of the kernel or other environments.

The first thing you should do is thoroughly familiarize yourself with the x86 interrupt and exception mechanism. 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 this lab we generally follow Intel's terminology, but be aware that terms such as exceptions, traps, interrupts, faults and aborts have no standardized meaning across architectures or operating systems, and often used rather loosely without close 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.

Exercise 5. Read Chapter 9, Exceptions and Interrupts in the 80386 Programmer's Manual (or Chapter 5 of the IA-32 Developer's Manual), if you haven't already.

Basics of Protected Control Transfer

In order to ensure that protected control transfers are actually protected, the processor's interrupt/exception mechanism is designed so that the code running when an interrupt or exception occurs has strictly limited influence over where and how the kernel is entered. Instead, the processor ensures that the kernel can be entered only under carefully controlled conditions. On the x86, this protection is provided by two particular mechanisms:

  1. 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 currently running when the interrupt or exception is taken.

    In particular, x86 interrupts and exceptions are differentiated into up to 256 possible "types", each associated with a particular interrupt number (often referred to synonymously as an exception number or trap number). Once the processor identifies a particular interrupt or exception to be taken, it uses the interrupt number as an index into the processor's interrupt descriptor table (IDT), which is a special table that the kernel sets up in kernel-private memory, much like the GDT. From the appropriate entry in this table the processor loads:

    • The value to load into the instruction pointer (EIP) register, which points to the kernel code designated to handle that type of exception.
    • The value to load into the code segment (%cs) register, which includes in bits 0-1 the privilege level at which the exception handler is to run. (In JOS, all exceptions are handled in kernel mode, or privilege level 0.)
  2. The Task State Segment. In addition to having a well-defined entrypoint in the kernel for an interrupt or exception handler, the processor also 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 easily 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 not only loads new values into %eip and %cs, but also loads new values into the stack pointer (%esp) and stack segment (%ss) registers, effectively switching to a new stack private to the kernel. The processor then pushes the original values of all of these registers, along with the contents of the eflags register, onto this new kernel stack before starting to run the kernel's exception handler code. The new %esp and %ss do not come from the IDT like %eip and %cs, but instead from a separate structure called the task state segment (TSS).

    Although the TSS is a somewhat large and complex data structure that can potentially serve a variety of purposes, in JOS it will only be used 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; none of the other fields in the TSS will ever ever be used in JOS.

Types of Exceptions and Interrupts

All of the synchronous exceptions that the x86 processor can generate internally use interrupt numbers between 0 and 31, and therefore map to IDT entries 0-31. For example, the page fault handler is "hard-wired" by Intel to interrupt number 14. Interrupt numbers 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 the 0-31 that are currently defined by Intel. In the next labs, we'll make JOS handle software interrupt number 48, which it (fairly arbitrarily) uses as its system call interrupt number, and extend it to handle externally generated hardware interrupts such as the clock interrupt.

A Kernel-Mode Exception Example

Let's put these pieces together and trace through an example. Say the processor is executing code in kernel mode (the low 2 bits of the %cs register are 0), and encounters a divide instruction that attempts to divide by zero. Then:

  1. The processor pushes the exception parameters on the kernel stack.
                         +-------------------+ <-- old ESP
                         |     old EFLAGS    |        " - 4
                         | 0x0000  | old CS  |        " - 8
                         |      old EIP      | <-- ESP = old ESP - 12
                         +-------------------+  
  2. Because we're handling a divide error, which is interrupt number 0 on the x86, the processor reads IDT entry 0 and sets CS:EIP to point to the handler function defined there.
  3. The handler function takes control and handles the exception.
  4. When it's finished, the handler function can return from the interrupt by executing an iret instruction.

For certain types of x86 exceptions, 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. (Exceptions 17, 18, and 19 are new since the 80386; see the IA-32 Architecture manual Volume 3, Section 5.) When the processor pushes an error code, the stack would look as follows at the beginning of the exception handler:

                     +-------------------+ <-- old ESP
                     |     old EFLAGS    |         " - 4
                     | 0x0000  | old CS  |         " - 8
                     |      old EIP      |         " - 12
                     |     error code    | <-- ESP = old ESP - 16
                     +-------------------+  

There is one important caveat to the processor's kernel-mode 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, any decent kernel should be designed so that this will never happen unintentionally.

Of course, user-mode programs can divide by zero too! The processor handles user-mode exceptions slightly differently to provide protection. It would not be safe to simply push interrupt context information onto a user program stack. (For one thing, a user program that runs out of stack space must not cause the processor to reset!) Thus, when a user-mode program takes an exception, the kernel switches to a special kernel stack. Information about the old stack is pushed onto the exception stack first, above the old EFLAGS:

                     +-------------------+ <-- KSTACKTOP
                     | 0x0000  | old SS  |         " - 4
                     |      old ESP      |         " - 8
                     |     old EFLAGS    |         " - 12
                     | 0x0000  | old CS  |         " - 16
                     |      old EIP      | <-- ESP = KSTACKTOP - 20 (maybe)
                     | (optional errcode)| <-- ESP = KSTACKTOP - 24 (maybe)
                     +-------------------+  

You'll handle user-mode exceptions in the next lab; just remember that there are differences in calling convention between kernel-mode and user-mode exceptions.

Setting Up the IDT

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 all the to handle interrupt numbers 0-31 (the processor exceptions). We'll add additional interrupts later.

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 trap-related definitions that will remain strictly private to the kernel, while the companion header file inc/trap.h contains general definitions that may also be useful to user-level programs and libraries in the system.

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:         +---> void trap(struct Trapframe *tf)
|                |             // do stuff     |     {
|                |             call trap  -----+         // handle the exception/interrupt
|                |                        <----+         return;
|                |             // undo stuff   +---- }
+----------------+
|   &handler2    |--------> handler2:
|                |            // do stuff
|                |            call trap
|                |            // undo stuff
+----------------+
       .
       .
       .
+----------------+
|   &handlerX    |--------> handlerX:
|                |             // do stuff
|                |             call trap
|                |             // undo stuff
+----------------+

Each exception or interrupt should have its own handler in trapentry.S and idt_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 into trap() (in trap.c) with a pointer to the Trapframe.

After control is passed to trap(), that function handles the exception/interrupt or dispatches the exception/interrupt to a specific handler function. If and when the trap() function returns, the code in trapentry.S restores the old CPU state saved in the Trapframe and then uses the iret instruction to return from the exception.

Exercise 6. Edit trapentry.S and trap.c and implement the functionality 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. Hint: your code should perform the following steps:
  1. Push values on the stack in the order defined by struct Trapframe and its component struct PushRegs.
  2. Load GD_KD into %ds and %es. This value is defined in <inc/memlayout.h> and in pmap.c. This step isn't that important for this lab, but will become vital later.
  3. pushl %esp to pass a pointer to Trapframe that was built on the stack.
  4. call trap.
  5. Pop the values pushed in steps 1-3.
  6. Clean up the stack. (How? Why?)
  7. iret.

Consider using the pushal and popal instructions; they fit nicely with the layout of struct PushRegs. (Remember that we draw stack diagrams with addresses increasing up the page, but C structures are written with addresses increasing down the page.)

You will also need to modify idt_init() to initialize the idt to point to each of these entry points defined in trapentry.S. Check out the SETGATE macro in <inc/mmu.h>.

Challenge! You probably have a lot of very similar code right now, between the lists of TRAPHANDLERs 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. You will need to switch between laying down code and data in the assembler by using the directives .text and .data.

Questions:
  1. What is the purpose of having an individual handler function for each exception/interrupt? (If all exceptions/interrupts were delivered to the same handler, what functionality that exists in the current implementation could not be provided?)
  2. Why is the loading of GD_KD (Step 2 of the exercise) not important for this lab, and why will it become important later?

The Breakpoint Exception

Now that your kernel has basic exception handling capabilities, you'll refine its response to one particular exception. Much more of this will happen in the next lab once we have user processes.

The breakpoint exception, interrupt number 3 (T_BRKPT), is normally used to allow debuggers to insert breakpoints in a program's code by temporarily replacing the relevant program instruction with the special 1-byte int3 software interrupt instruction. In JOS we will abuse this exception slightly by turning it into a primitive pseudo-system call that anyone can use to invoke the JOS kernel monitor, inside or outside the kernel. This usage is actually somewhat appropriate if we think of the JOS kernel monitor as a primitive debugger.

Exercise 7. Modify trap() to make breakpoint exceptions invoke the kernel monitor.

Also, change the mon_backtrace() function from Lab 1 so that, when invoked during a breakpoint, it prints a backtrace starting from the kernel function that caused the trap. (Eventually, you will extend this to user processes too.) You will use the struct Trapframe's instruction pointer component to print the topmost frame. A breakpoint backtrace should look something like this (note the different format for frame 0):

Stack backtrace:
  0: eip f0100047
         kern/init.c:46: _ZL22test_kernel_breakpointv+ac7 (0 arg)
  1: ebp f0117fd8  eip f0100145  args f0104169 00001aac 00000000 00000000
         kern/init.c:215: i386_init+4c (0 arg)
  2: ebp f0117ff8  eip f010003d  args 00000000 00000000 0000ffff 10cf9a00
         kern/entry.S:79: <unknown>+0 (0 arg)

Finally, add an "exit" command to the kernel monitor that exits the kernel monitor, returning from the interrupt. Make sure that you can safely exit a breakpoint kernel monitor. When you are done, make grade should return 100/100.

Challenge! Modify the JOS kernel monitor so that you can step execution from the current location (e.g., after the int3, if the kernel monitor was invoked via the breakpoint exception), one instruction at a time. You will need to understand certain bits of the EFLAGS register in order to implement single-stepping.

Optional: If you're feeling really adventurous, find some x86 disassembler source code - e.g., by ripping it out of Bochs, or out of GNU binutils, or just write it yourself - and extend the JOS kernel monitor to be able to disassemble and display instructions as you are stepping through them. Combined with the symbol table loading functionality suggested by one of the challenge problems in the previous lab, this is the stuff of which real kernel debuggers are made.

This completes the lab.

Back to CS 235 Advanced Operating Systems