CS 134

Written Component

Everyone has now turned in their answers and they have been graded. You can find the grading by looking at the graded-written branch of your repository on GitHub, where you'll find your written.md file has been updated with comments, and there is a new file WRITTEN-GRADING.md that provides a summary of your grade.

DO NOT SHARE OR DISTRIBUTE THE MODEL SOLUTIONS.

A: Loading and Running User-Level Programs; Dealing with User-Space Memory

  1. What are the ELF magic numbers?

    The ELF magic numbers are a sequence of bytes at the beginning of an ELF (Executable and Linkable Format) file that identify it as an ELF file. They are defined in elf.h as follows:

    #define ELFMAG0    0x7f
    #define ELFMAG1    'E'
    #define ELFMAG2    'L'
    #define ELFMAG3    'F'
    

    These magic numbers form the byte sequence char(0x7f)+"ELF" at the start of every ELF file. They are used for consistency checking and to quickly identify ELF files.

  2. What function forces the processor to switch into usermode? Is this function machine dependent?

    The function that forces the processor to switch into usermode is mips_usermode(), which is defined in kern/arch/mips/locore/trap.c. This function is indeed machine-dependent, as evidenced by its location in the kern/arch/mips directory and its call to MIPS-specific assembly code.

    The process of switching to usermode involves the following steps:

    1. enter_new_process() in runprogram.c calls mips_usermode() to switch to usermode.
    2. mips_usermode() then calls asm_usermode(), which is written in MIPS assembly.
    3. asm_usermode() sets up the necessary CPU registers and uses the eret (Exception Return) instruction to switch to usermode.
  3. In what file are copyin and copyout defined? Why can't we just copy directly (e.g., using memcpy)?

    copyin and copyout are defined in kern/vm/copyinout.c. We can't use memcpy directly for several reasons:

    • Safety: copyin and copyout perform important checks to ensure the memory being accessed is within the proper userspace region. This is done using the copycheck() function.
    • Fault handling: These functions use copyfail as a recovery point if a fatal fault occurs during copying. This allows the kernel to handle errors gracefully without crashing.

    (After performing these checks and setups, copyin and copyout do actually use memcpy for the data transfer.)

  4. What (briefly) is the purpose of userptr_t? What's the difference between userptr_t and vaddr_t, both technically and in intent?

    userptr_t is a type used to represent pointers to user-space memory. Its purpose is to:

    • Clearly distinguish pointers to user memory (and originating from user code, thus not entirely trustworthy) from pointers to kernel memory.
    • Prevent accidental dereferencing of user pointers in kernel code.

    userptr_t is defined as a pointer to a dummy struct containing a single char. This allows pointer arithmetic (with no implicit multipler, since the object size is 1) but prevents accidental dereferencing as the result will be a dummy struct that is unlikely to be what the erroneous code expects.

    vaddr_t, on the other hand, is defined as an unsigned 32-bit integer (uint32_t) and represents a virtual memory address. It's used more generally in memory-management operations. By being defined as an integer (that's the same bit width as a pointer), we can perform arithmetic on it, but we cannot dereference it.

    The key differences are:

    • Intent: userptr_t is specifically for referencing user-space memory, while vaddr_t is used for general virtual address manipulation in both user and kernel space.
    • Trust: userptr_t values are considered untrusted and require careful handling, while vaddr_t values are generally trusted within the kernel.
    • Usage: userptr_t is primarily used with copyin and copyout, while vaddr_t is used in various memory management operations.

    Deeper dive: Why is one an integer and the other a pointer? The answer lies in the existance of const_userptr_t and non-existance of const_vaddr_t. In C code, we want to be able to pass non-const pointers into functions that take const pointers, but not the converse. By making userptr_t an actual pointer, it'll follow the usual C rules for const-ness. Because we don't make the “pointing to const memory” distinction for vaddr_t, we don't need to make it a pointer to ensure the appropriate const-correctness.

B: Virtual File System

  1. As you were reading the code in runprogram.c and loadelf.c, you probably noticed how the kernel manipulates the files. Which kernel function is called to open a file? Which macro is called to read the file? What about to write a file? Which data structure is used in the kernel's VFS subsystem to represent an open file?

    • To open a file: vfs_open() function
    • To read a file: VOP_READ macro
    • To write to a file: VOP_WRITE macro
    • Data structure representing an open file: struct vnode, which we always refer to using a pointer (struct vnode *) since the VFS layer manages the actual memory holding the vnode.

    (Note: vfs_open takes a struct vnode ** as its last argument, which is a pointer to struct vnode *. This is how it returns the vnode of the opened file to the caller, because the function uses its normal return value to return an error code.)

    Deeper dive: The vnode (virtual node) structure is an abstract representation of a file or directory in the VFS. It includes: - A reference count - A lock for the reference count - A pointer to the filesystem it belongs to - Filesystem-specific data - Function pointers for operations on this vnode

  2. What is the difference between UIO_USERISPACE and UIO_USERSPACE? When should one use UIO_SYSSPACE instead?

    • UIO_USERISPACE: Refers to user process code (instruction space)
    • UIO_USERSPACE: Refers to user process data

    Both are used to indicate that the memory being accessed is in user space. The distinction between instruction and data space is not (currently) significant in OS/161, but it's likely good practice to make the distinction anyway (e.g., it could be important in systems with separate I and D caches or with a Harvard architecture).

    UIO_SYSSPACE should be used when the memory being accessed belongs to the kernel (kernel space). This is important because:

    • It allows the system to use more efficient copying methods (like directly calling memmove) instead of copyin/copyout.
    • It indicates that the memory is trusted and doesn't need the same level of checking as user space memory.
  3. Why can the struct uio that is used to read in a segment be allocated on the stack in load_segment? (i.e., where does the memory read actually go?)

    The struct uio can be allocated on the stack in load_segment because:

    1. It's a small, fixed-size structure that doesn't contain the actual data being transferred.
    2. It only contains metadata about the transfer, such as pointers, offsets, and sizes.
    3. The uio struct is only needed for the duration of the load_segment function call.

    The actual memory read goes to the location specified by the vaddr parameter in the load_segment function, which is typically in the user's address space. The uio struct just describes how this transfer should occur.

  4. In runprogram, why is it important to call vfs_close before going to user mode?

    It's crucial to call vfs_close before entering user mode because:

    • The enter_new_process function doesn't return, so this is the last chance to close the file properly. (What is on the kernel stack at this moment will be lost. Although the user program will likely reenter the kernel to perform system calls or because of interrupts, that will be a new entry into the kernel.)

    Of course, in general, it is always good to close a file when you are done with it for all the usual resource-management reasons.

  5. What are VOP_READ and VOP_WRITE? How are they used? What does VOP_ISSEEKABLE tell you about a vnode?

    VOP_READ and VOP_WRITE are macros that provide an abstract interface for reading from and writing to files, respectively. They are part of the VFS (Virtual File System) layer and allow uniform access to different types of file systems.

    Usage:

    • VOP_READ(vn, uio): Reads data from the file represented by vnode vn into the memory described by uio.
    • VOP_WRITE(vn, uio): Writes data from the memory described by uio to the file represented by vnode vn.

    These macros handle the necessary locking and call the appropriate file system-specific functions.

    VOP_ISSEEKABLE is a macro that checks if a file is seekable. It returns a boolean value indicating whether the file supports random access (seeking to arbitrary positions). - Regular files and directories are typically seekable. - Some devices (like keyboards or network sockets) may not be seekable.

    This information is useful for determining what operations can be performed on a file and how to handle it in the VFS layer.

  6. The path argument to vfs_open is not const. Investigate, and determine whether there are any potential gotchas with using vfs_open.

    The path argument to vfs_open is not const because it may be modified during the process of opening the file.

    Specifically, in the getdevice function (called by vfs_lookup, which is called by vfs_open), the path string may be modified if it contains a colon. The colon is replaced with a zero byte (NUL terminator) to separate the device name from the rest of the path. (FWIW, this approach is used to avoid duplicating the path string, which would be inefficient, especially for long paths.)

    The possible gotcha is that if the caller of vfs_open expects to use the path string after the call, they it may have been altered, potentially causing bugs or unexpected behavior.

    To avoid these issues, callers of vfs_open should be aware that the function may modify the path string, and should use a copy of the path if they need to preserve the original.

C: Traps & System Calls

  1. What is the numerical value of the exception code for a MIPS system call?

    The numerical value of the exception code for a MIPS system call is 8. This is defined in kern/arch/mips/include/trapframe.h as:

    #define EX_SYS         8      /* System call */
    
  2. How many bytes is an instruction in MIPS? (Looking at the end of the code for the syscall function in mips/trap.c will give you the answer.)

    An instruction in MIPS is 4 bytes. This can be deduced from the following code in the syscall function in kern/arch/mips/locore/trap.c:

    /*
    * Now, advance the program counter, to avoid restarting
    * the syscall over and over again.
    */
    tf->tf_epc += 4;
    

    Here, the program counter (tf_epc) is incremented by 4 bytes to move to the next instruction.

  3. What does the syscall function do with the trapframe that is passed into it? And where is the trapframe that is passed into syscall stored?

    The syscall function uses the trapframe to:

    1. Extract the system call number and arguments from the saved registers.
    2. Execute the appropriate system call based on the call number.
    3. Store the return value and error status in the trapframe.
    4. Advance the program counter to the next instruction.

    The trapframe is stored on the kernel stack of the current thread. It's initially created in the assembly code in exception-mips1.S when an exception occurs. The assembly code saves all the necessary registers onto the stack and then calls mips_trap, which in turn calls syscall for system call exceptions.

    (Note: In lab I helped some students see that the stack was used, but didn't draw their attention to the fact that it was the kernel stack. Sorry about that. If you got this aspect wrong, we'll still give you full credit but do be aware of this detail as it matter for the assignment.)

  4. Why do you “probably want to change” the implementation of kill_curthread?

    It's important to realize that kill_curthread is not thread_exit. It's not called when a thread exits normally, it's called when the thread encounters a fatal error, such as accessing invalid memory or executing an illegal instruction.

    The current implementation of kill_curthread simply prints an error message and then panics, causing the entire kernel to shut down. This is obviously problematic, as we never want the entire system to crash just because one thread misbehaved.

    A better implementation would:

    • Terminate the process containing the current thread as something has clearly gone badly wrong in the process.
      • This likely involves noting the reason the process was killed wherever we track the process's state for waitpid and such.
    • Schedule another thread to run, so the system can continue operating.

    These steps are somewhat akin to calling sys_exit with a non-zero status.

    This approach allows the system to continue running even if one thread encounters a fatal error.

    (Note: If you thought a process could continue after one thread crashed, you'll get full credit, but understand that a thread crashing is a sign that the process is in a bad state and should be terminated. Also, understand that when a user process is in a bad state, we can't really trust anything inside it, so the kernel should probably not try to read any data from it or write any data to it, or cause it to continue running.)

  5. What would be required to implement a system call that took more than four arguments?

    System calls follow the standard MIPS calling convention (a.k.a. the MIPS ABI [Application Binary Interface]), which passes the first four arguments in registers a0-a3, and from that point on, arguments are passed on the user stack. We don't implement that ourselves, that's just how it works already. We just have to deal with that being the way things are.

    Thus, to implement a system call with more than four arguments, we need to:

    • Read the first four arguments out of the registers as usual.
    • Read additional arguments from the user stack using copyin.

    They would be placed at sp+16 and upwards, so we would use code like:

    int arg5;
    err = copyin((userptr_t)tf->tf_sp + 16, &arg5, sizeof(int));
    
  6. User-space code refers to a global variable called errno. How does errno get set?

    The errno variable is set in user space, not directly by the kernel. The process works as follows:

    • When a system call fails, the kernel sets the error code in the v0 register of the trapframe and also sets the a3 register to 1 to indicate an error occurred.
    • When control returns to user space, the assembly code in syscalls-mips.S checks the a3 register. If a3 is set to 1 (indicating an error), the assembly code:
      • Stores the value from v0 into the global errno variable.
      • Sets v0 to -1 to indicate failure to the C code that called the system call.

    This approach allows C programs to check the return value of system calls for -1 and then check errno for the specific error that occurred. It also ensures that errno is only set when a system call fails, not when it succeeds.

  7. Our MIPS processor is a 32-bit CPU, but lseek() takes and returns a 64-bit offset value. Thus, lseek() takes a 32-bit file handle (arg0), a 64-bit offset (arg1), a 32-bit whence (arg3), and needs to return a 64-bit offset value. In void syscall(struct trapframe *tf), where will you find each of the three arguments (in which registers) and how will you return the 64-bit offset? (Hint: Read syscalls-mips.S and syscall.c.)

    The arguments for lseek() are passed as follows:

    • arg0 (32-bit file handle): Register a0
    • arg1 (64-bit offset):
    • Lower 32 bits: Register a2
    • Upper 32 bits: Register a3
    • arg3 (32-bit whence): On the stack (since a0-a3 are used)
      • specifically, it is located at tf->tf_sp + 16, which should be cast to a userptr_t and then used with copyin to read the value.

    To return the 64-bit offset:

    • Lower 32 bits: Register v0
    • Upper 32 bits: Register v1

    This arrangement ensures that 64-bit values are aligned on 64-bit boundaries (pairs of registers), which is a requirement of the MIPS ABI.

    (Note: kern/include/endian.h defines to handy functions, join32to64 and split64to32, that can be used to combine and split 32-bit values into a 64-bit value. You might want to use them in your implementation. For a clean solution, it may be helpful to add a int64 retval64; general variable to your syscall function to hold 64-bit return values, and a boolean, defaulting to false, to indicate if the return value is 64-bit, and some logic at the end of the function to decide whether to return a 32-bit or 64-bit value.)

D: Process Support

  1. struct thread includes t_proc, a pointer to a struct proc. What code in thread.c uses or manipulates t_proc, to do what?

    In thread.c, t_proc is used in several functions. The most relevant to us is

    int thread_fork(const char *name, struct proc *proc,
                void (*func)(void *, unsigned long),
                void *data1, unsigned long data2);
    

    where we can supply the desired process for the new thread to be associated with, which is done by calling proc_addthread() to add the new thread to the process's thread list.

    (Note: If we don't supply a process, the new thread will be associated with the current process, but that is less relevant to us in the current assignment, as all new threads we create will be associated with new processes we've just created, because we aren't supporting multi-threaded processes. The only “process” that has multiple threads is the kernel process, which is a special case in several ways, and posibly shouldn't be called a process at all.)

    In addition, t_proc is used in following places:

    • thread_bootstrap(): Sets the kernel main thread's t_proc to the current process (likely the kernel process).
    • thread_create(): Initializes t_proc to NULL. (Note, BTW, that confusingly thread_create does not create a running thread; it only initializes a new struct thread.)
    • thread_exit(): Calls proc_remthread() to disassociate the thread from its process.
    • thread_detach() and thread_destroy(): Asserts that t_proc is NULL, ensuring the thread is not associated with any process when detached or destroyed.
    • cpu_create() also calls proc_addthread(), but this is largely irrelevant to supporting user processes.

    These uses of t_proc help maintain the relationship between threads and their associated processes, ensuring proper process management and preventing errors like destroying threads that are still linked to processes.

  2. The enter_forked_process function exists to “return” from the fork() system call in a newly created child process. Why is return in quotes? Where will the trapframe it needs come from? What's the missing code needed in this function so that in the child process, the return value of fork() is 0?

    “Return” is in quotes because enter_forked_process doesn't actually return in the traditional sense. The child process is brand new, so technically it is starting up, not returning. However, because it has complete copy of its parent's memory, because the parent just called the kernel with a system call, the memory state of the child looks the same way. So, the child process is in the same state as the parent was when it called fork(), and expecting to be returned to.

    So, enter_forked_process needs to deal with user-mode code that is expecting to return from the fork() system call.

    The trapframe needed by enter_forked_process comes from:

    • The parent process's trapframe, which needs to copied during the fork() operation.
    • This copied trapframe is then modified to set up the child process correctly.

    To ensure that the child process's fork() call returns 0, the following code needs to be added to enter_forked_process:

    // Set return value to 0 for the child process
    tf->tf_v0 = 0;
    // Indicate no error
    tf->tf_a3 = 0;
    

    This code modifies the copied trapframe to set the return value (in v0) to 0 and indicates no error (in a3), mimicking a successful return from fork() in the child process.

    (Note: Copying the trapframe is absolutely necessary. The original trapframe from the parent process has vanished from the kernel stack by the time we're in the child process, so we can't just use that one. We need to use kmalloc and make a copy before we create the child thread, and then free it in the startup code for the child process, making yet another copy, back on the kernel stack, that we can use for enter_forked_process.)

  3. What does kill_curthread do right now? What will it need to do when process support is implemented?

    [Opps, this is a bit of a repeat of question 4 from part C. Sorry about that!]

    Currently, kill_curthread:

    1. Sets a signal code based on the type of exception that occurred.
    2. Prints an error message with details about the fault.
    3. Calls panic(), which halts the entire system.

    When process support is implemented, kill_curthread should terminate current process. It should note the correct crashed-due-to-signal code in the process's state, and then schedule another thread to run. This allows the system to continue running even if one thread encounters a fatal error.

  4. There isn't currently an analogous proc_exit to match thread_exit. If there were, which one should call the other (and be called by your exit system call)?

    If a proc_exit function were to be implemented:

    1. The exit system call should call proc_exit.
    2. proc_exit should clean up any resources associated with the process, particularly closing any open files, record is exit status, and then call thread_exit to destroy the current thread.

    (Note: It might be more complex if processes had multiple threads. thread_exit() takes no arguments and exits the current thread and does not return. You cannot call it in a loop! There is no mechanism to kill other threads in OS/161 right now. If we had multiple threads per process, we'd need to add that mechanism, perhaps a new thread_terminate function that took a thread as an argument. Then we'd call thread_terminate for each additional thread in the process in proc_exit. Writing thread_terminate correctly would likely be tricky. Be glad you don't have to do this! If you thought we could have multi-threaded processes, that's understandable, and full credit, but understand that we're not doing that right now in OS/161. However, if you explicitly spoke of calling thread_exit in a loop, we can't give you full credit, because that shows a misunderstanding of how thread_exit works.)

To Complete This Part of the Assignment…

You'll know you're done with this part of the assignment when you've done all of the following:

(When logged in, completion status appears here.)