lightswitch

In which xv6's virtual memory grows up -- or perhaps down?

Unlike our previous projects, project 3 will be done entirely in xv6, and will focus on improving its virtual memory subsystem. You can download a fresh copy of the xv6 code here (please do not re-use your modified xv6 code from previous projects).

For the first part, you'll be making xv6 stop allowing user code to dereference null pointers.

For the second part, you'll be rearranging the address-space layout used for xv6 user processes to put the stack at the top end of the user address space, as we've usually seen in class.

For the third part, you'll be allowing a process's stack to automatically expand when needed.

For bonus points, you can implement support for read-only regions of user memory.

As with previous projects, this project is to be done individually. The usual policy of course applies.

Due: Wednesday, March 15 at 11:59 PM (Late policy)

Hand-in instructions are at the bottom of this page.

Prohibiting NULL pointer dereferences

In the existing virtual memory layout xv6 provides user processes, the very bottom (numerically lowest addresses) of the virtual address space is used for the code ("text") section of your process, with stack and heap following it:

    code (N pages starting at address zero)
    stack (1 page)
    heap (N pages growing up toward USERTOP)

This approach is nicely simple, but it has the unfortunate side-effect that NULL pointer dereferences by buggy programs are silently allowed, rather than crashing the process. For this part of the project, you'll be altering how user memory is laid out so that user processes can no longer get away with this:

    [one or more unmapped page(s) at address zero]
    code (N pages)
    stack (1 page)
    heap (N pages growing up toward USERTOP)

First, give yourself a tool to test things out with: create an xv6 user program that simply dereferences a null pointer -- perhaps something like:

printf(1, "At 0x0: %d\n", *(int*)0);

Run it and verify that it doesn't crash. (If you use this code, you should see output from the printf call.) For comparison, run a similar program on Linux and observe the all-too-familiar result: Segmentation fault.

Implementing this won't require writing a large amount of code, but it will require a fair amount of reading. The file kernel/vm.c will play a major role here; in particular, you should read and understand functions like walkpgdir(), mappages(), loaduvm(), allocuvm(), and copyuvm(). The exec() function in kernel/exec.c might also be useful for looking at how a process's address space is first set up.

When reading through the relevant kernel code, think about what assumptions it might be making about the user address space layout. There may also be checks that you need to modify -- recall that, as you hopefully saw in Project 2 with the argptr() function, the kernel needs to be very careful in validating any pointers it receives from user code before using them. Think about which checks like these will need to be altered with your new memory layout.

As with the xv6 portion of Project 2, you'll need to modify a linker flag in user/makefile.mk to change the base address used for the text section of your user programs so that they don't continue trying to execute instructions from an address that the kernel no longer allows access to.

Once you've made the (relatively small) changes necessary to prohibit null pointer accesses, you should be able to run your initial test program and see it crash -- xv6's existing trap-handling code will take care of catching the bad memory access and killing the process for you.

High-address stack

In its current, unmodified form, xv6 sets up each user process with a fixed-size stack between the code and heap regions of its virtual address space, with the heap growing upward into numerically-higher addresses. You'll now be altering the userspace virtual memory layout a bit further so that it more closely matches the layout we've typically been assuming in lectures: code toward the bottom (at numerically lower addresses), followed by an upward-growing (toward numerically higher addresses) heap, with the stack at the (numerically) high end of the virtual address space. Note though that since this will be built on top of part one, the very lowest page of the user virtual address space should remain unmapped:

    [one or more unmapped page(s) at address zero]
    code (N pages)
    heap (N pages growing up toward stack)
    [unmapped gap]
    stack (1 page just below USERTOP)

To do this, you'll need to rearrange the address space to put the current fixed-size (single-page) stack region at the very top of the user address space (see the USERTOP macro in the xv6 code), with an unmapped gap between the heap and the stack. You'll need to find where the xv6 kernel sets up the stack for a user process and alter it to put it at the high end of the address space. Since 32-bit x86 (the emulated hardware on which xv6 runs) passes arguments on the stack, you'll need to make sure that the initial stack setup also continues to pass argc and argv correctly to each executed program. (The echo command would be a simple way to check this.)

Note that this change will invalidate an assumption that is used in a few places in the xv6 kernel code: that the mapped portion of the user virtual address space is a single contiguous region (whose size is tracked by the sz element of the proc struct as the heap grows). You'll need to examine each place that uses sz and decide if it requires any modifications to remain correct with the new memory layout (and if so, make those modifications). You may want to keep sz around and just use it to track the size of the code-plus-heap area of the process's address space but add some extra accounting for the stack.

As the heap grows toward the stack you must ensure that it does not collide with the stack and overwrite it; there should always be at least one unmapped (inaccessible) page between the two. A memory allocation request (an sbrk() syscall) that would require mapping the last remaining page between them should not succeed.

On-demand stack resizing

The final piece of the project is the fanciest (and probably the hardest): removing the limitation of a fixed-size stack, and instead automatically expanding it when a user process needs more stack space:

    [one or more unmapped page(s) at address zero]
    code (N pages)
    heap (N pages growing up toward stack)
    [unmapped gap]
    stack (N pages growing down from USERTOP toward heap)

To achieve this you'll need to modify the trap-handling code (look at kernel/trap.c) to check if an invalid memory access falls in the page just beyond the current top of the stack (recall that the stack grows downward, numerically). On x86, the number that identifies a page fault (the relevant trap event) is 14 (as in the T_PGFLT macro in include/traps.h), and the address that triggered the fault is available from the special register CR2. Look for a print statement in kernel/trap.c (which might look familiar from previous xv6 experiments) to see how you might obtain these values.

If the trap was triggered by an attempt to access the first invalid page beyond the stack, instead of killing the process, allocate a new page and map it into the process's address space at an appropriate location such that it acts as a new page of stack space, and then continue executing the process. You should expand the stack in this way if and only if the trapping access is within one page of the current end of your stack (so that accesses that are way out of bounds don't cause the stack to suddenly grow enormously). To test your stack-expansion mechanism, you might write a simple xv6 user program that uses some combination of recursion and/or large on-stack buffers (e.g. a local char foo[BIGNUM]) to consume stack space in a controlled fashion.

You must still always maintain at least one unmapped page between the stack and the heap; if a fault-triggered stack expansion would require the final remaining page between the two to become mapped, the process should be killed as it would with an invalid memory access.

Bonus: Read-only code/data sections

For bonus points: implement support for mapping some regions of user memory with read-only permissions.

If you do this appropriately, it should "automatically" be applied to both program text (code) and read-only data (such as string constants) in user programs. You can test this by running programs that attempt to write to their own instructions or read-only data, such as:

char* p = (char*)&main;
*p = 'x';

or

char* s = "this is a string constant";
*s = 'x';

With proper support for read-only mappings, both of these should produce a crash. (Other programs should continue functioning normally.)

Implementing this takes very little code -- maybe a dozen or so lines, give or take. Figuring out exactly what those lines should be may be tricky, however.

Some hints on things to look into:

UPDATE: If you're attempting the bonus, download this file, copy it into your xv6 code as user/ulink.lds, and then modify user/makefile.mk to add the line USER_LDFLAGS += -dTuser/ulink.lds as the FIRST assignment of USER_LDFLAGS (i.e. put that line before the one commented # FreeBSD ld wants ``elf_i386_fbsd''). This will give you a much more reasonable starting point with the versions of the compiler & linker installed on the CS lab machines.

NOTE ALSO: If you do attempt the bonus make sure to mention it in your README file so that your project gets graded as such.

Testing your implementation

Some simple test programs (one for each of the three main parts of the project) can be downloaded here. These are xv6 userspace programs; a comment in each one describes what the result of running it should be. Due to the cumulative nature of the three parts of the project (each one building on the last), after implementing part 2 you should still pass the part 1 test, and after part 3 you should pass all three.

Note that while these tests cover most of the required functionality, it does not test everything -- most notably, it does not verify that you always maintain an unmapped page between the stack and the heap for parts 2 and 3. (You should devise a way to test this!)

UPDATE: The modifications made for this project should in general not cause any existing xv6 programs to stop working (i.e. things like ls and cat and so forth in the xv6 shell should work as before). There is one exception to this, however: the usertests program has some assumptions about its address space hardcoded into it that are invalidated by the changes made for this project, so if you find that usertests (its sbrk test, specifically) starts failing, don't worry -- that's expected.

Handing in your code

The handin procedure is similar to your other projects:

$ cd YOUR_XV6_DIRECTORY
$ make clean
$ cp -r . ~cs537-2/handin/$USER/p3/xv6
# create a brief README file (mentioning bonus if you did it)
$ cp README ~cs537-2/handin/$USER/p3

If you're attempting the bonus but are concerned about changes for that potentially messing up your work, you can hand in the non-bonus parts of the project as above and then create & use a separate handin directory for the bonus version of your code:

# if you do this, it would be another good thing to mention in your README
$ mkdir ~cs537-2/handin/$USER/p3/xv6-bonus