Project 2 requires you to implement signals and signal handlers. A signal delivered to a process will interrupt what it is doing to process the event.
A signal is an inter-process communication mechanism that involves causing one of a small array of functions to be invoked in another process. Each process can use the "signal" system call to manipulate a table that lists signal handlers (function pointers) to be invoked at the request of another process. To send a signal, a process calls the "kill" system call with the pid of the target and the signal number to send. The signal number represents the index into the table of signal handlers in the target process. The kernel will then arrange for the signal handler function to be called within the process. The slightly tricky part is to ensure that when the signal handler returns, control is passed back to the kernel and the process can resume from wherever it was. To accomplish this task, we define a "return signal" call that will be invoked as the signal handler returns. In implementing signals, you will need to arrange for the process to have both a context when executing the signal handler and a saved context that the signal handler will return to after execution. In preparation, we describe context switching next.
The kernel stack is the stack used by a Geekos kernel thread while it is executing in the kernel. As usual, the kernel stack stores the local variables used by the kernel thread while running GeekOS kernel routines. This could be for kernel threads performing system processes, like the reaper thread, or it could be for kernel threads implementing user processes, executing system calls on their behalf.
When performing a context switch, the current state (or "context") of a thread is saved on its kernel stack. This state consists of the current values of (most of) the registers. The fields stackPage and esp defined in the Kernel_Thread structure, specify where the thread's kernel stack is (esp is the kernel stack pointer). This way, when a thread is to be context-switched to, the current thread switches to the new thread's stack, and then restores the context.
Stacks grow downward, from numerically higher addresses to numerically lower addresses.
User processes have a kernel stack, for calls within the kernel when the kernel is running on the process's behalf, and a user stack, for local variables while running user-level code.
To prepare a user process to be run for the first time, GeekOS pushes the same state on the kernel stack that it would have had, if it has been previously running and interrupted in a systme call or by being preempted. The state pushed onto the kernel stack includes the following:
When the thread is scheduled for the first time, these initial values will be loaded into the corresponding processor registers and the thread can run. The initial stack state for a user thread is described in the following figure (check Setup_User_Thread() in src/geekos/kthread.c):
User Stack Data Selector (data selector) |
User Stack Location |
User Stack Pointer (to end of user's data segment) |
|
Eflags |
Interrupt_State |
Text Selector (code selector) |
|
Program Counter (entry addr) |
|
Error Code (0) |
|
Interrupt Number (0) |
|
EAX (0) |
|
EBX (0) |
|
ECX (0) |
|
EDX (0) |
|
ESI (Argument Block address) |
|
EDI (0) |
|
EBP (0) |
|
DS (data selector) |
|
ES (data selector) |
|
FS (data selector) |
|
GS (data selector) |
The items at the top of this diagram (in high memory) are pushed first, the items at the bottom (in lower memory) are pushed last (i.e., the stack grows downward). In this figure, the contents of the stack, not including the user stack location, are defined in struct Interrupt_State in geekos/int.h. This is the structure you're familiar with from modifying system calls in syscall.c.
The user stack selector is the same as the data selector: both the stack and the data segment occupy the same memory segment. The user stack starts at the high end of the data segment and grows downward. Initially, the user stack pointer should indicate an empty stack. So it points to the end of the data segment.
When switching from kernel mode to user mode, the kernel calls Switch_To_User_Context() in src/geekos/user.c. Switching the context includes the following steps:
This project will require you to make changes to several files. In general, look for the calls to the TODO() macro. These are places where you will need to add code, and they will generally contain a comment giving you some hints on how to proceed. There are three primary goals of this project:
In this project, you must implement signal handling and delivery for the following four signals (defined in include/geekos/signal.h):
SIGKILL:
This is is the signal sent to a process to kill it. You should write the handler for this signal such that it results in the same behavior as in project 1's Sys_Kill. The process is not permitted to install a signal handler for SIGKILL.
SIGUSR1, SIGUSR2:
"User-defined" signals with no pre-determined purpose. These will be sent only by other processes.
SIGCHLD:
When a child process dies, if its parent is not already blocked Wait()ing for it, a SIGCHLD signal should be sent to the parent process. For this project, a "background" process must keep its parent (owner points where it belongs and the initial refCount should be 2). When a background process dies, the parent can be informed of this fact by SIGCHLD, and thus can reap the child, using the Sys_WaitNoPID system call, defined below.
Further Reading: More information about signal handling can be found in Chapter 4 of the text.
In this project, you will implement five system calls; the user-space portion of these calls is defined for you in src/libc/signal.c:
This system call registers a signal handler for a given signal number. The signal handler is a function that takes the signal number as an argument (it may not be useful to it), processes the signal in some way, then returns nothing (void). If called with SIGKILL, return an error (EINVALID). The handler may be set as the pre-defined "SIG_DFL" or "SIG_IGN" handlers. SIG_IGN tells the kernel that the process wants to ignore the signal (it need not be delivered). SIG_DFL tells the kernel to revert to its default behavior, which is to terminate the process on KILL, USR1, and USR2, and to discard (ignore) SIGCHLD. A process may need to set SIG_DFL after setting the handler to something else.
The signal handling infrastructure requires a special "trampoline" function to be implemented. This "trampoline" invokes the system call Sys_ReturnSignal (see below) at the conclusion of signal handler. This system call is invoked by Sig_Init when called by the _Entry function in src/libc/entry.c; this function is invoked prior to running the user program's main().
Sys_Kill:
In project 1, this system call took as its argument the PID of a process to kill. In this project, it will be used to send a signal to a certain process. So in addition to the PID, Sys_Kill will take a signal number: one of the four defined above. It should be implemented as setting a flag in the process to which the signal is to be delivered, so that when the given process is about to start executing in user space again, rather than returning to where it left off, it will execute the appropriate signal handler instead.
Sys_ReturnSignal:
This system call is not invoked by user-space programs directly, but rather is executed by some stub code at the completion of a signal handler. That is, Sys_Kill/Send_Signal sends process P a signal, which causes it to run its signal handler. When this handler completes, we will have set up its stack so that it will "return" to the trampoline registered by Sys_RegDeliver. This trampoline wil invoke Sys_ReturnSignal to indicate that signal handling is complete.
The Sys_Wait system call takes as its argument the PID of the child process to wait for, and returns when that process dies. The Sys_WaitNoPID call, in contrast, takes a pointer to an integer as its argument, and reaps any zombie child process that happens to have terminated. It places the exit status in the memory location the argument points to and returns the pid of the zombie. If there are no dead child process, then the system call should return ENOZOMBIES.
If the default handler is invoked for SIGKILL, SIGUSR1, or SIGUSR2, Print("Terminated %d.\n", g_currentThread->pid); and invoke Exit.
Sending a signal should appear as if setting a flag in the PCB about the pending signal; the signal handler should not necessarily be executed immediately. In particular, if the process is executing a signal handler, it cannot start executing another signal handler. Further, multiple invocations of kill() to send the same signal to the same process before it has a chance to handle even one will have the same effect as just one invocation of kill(). Concretely, if two children finish while a handler is executing (and blocked), the SIGCHLD handler will be called only once. The delivery order of pending signals is not specified (they need not be queued).
To implement signal delivery, you will need to implement (at least) five routines in src/geekos/signal.c:
Send_Signal:
this takes as its arguments a pointer to the kernel thread to which to deliver the signal, and the signal number to deliver. This should set a flag in the given thread to indicate that a signal is pending. This flag is used by Check_Pending_Signal, described next.
Check_Pending_Signal:
this routine is called by code in lowlevel.asm when a kernel thread is about to be context-switched to. It returns true if the following THREE conditions hold:
Set_Handler:
use this routine to register a signal handler provided by the Sys_Signal system call.
Setup_Frame:
this routine is called when Check_Pending_Signal returns true, to set up a user process's user stack and kernel stack so that when it starts executing, it will execute the correct signal handler, and when that handler completes, the process will invoke the Sys_ReturnSignal system call to go back to what it was doing. IF instead the process is relying on SIG_IGN or SIG_DFL, handle the signal within the kernel. IF the process has defined a signal handler for this signal, this function will have to do the following:
1. Choose the correct handler to invoke.
2. Acquire the pointer to the top of the user stack. This is below the saved interrupt state stored on the kernel stack as shown in the figure above.
3. Push onto the user stack a snapshot of the interrupt state that is currently stored at the top of the kernel stack. The interrupt state is the topmost portion of the kernel stack, defined in include/geekos/int.h in struct Interrupt_State, shown above.
4. Push onto the user stack the number of the signal being delivered.
5. Push onto the user stack the address of the "signal trampoline" that invokes the Sys_ReturnSignal system call, and was registered by the Sys_RegDeliver system call, mentioned above.
6. Change the current kernel stack such that (notice that you already saved a copy in the user stack)
(1)
The user stack pointer is updated to reflect the changes made in step 3
& 4.
(2) The saved program counter (eip) points to the signal handler.
Complete_Handler:
this routine should be called (by your code) when the Sys_ReturnSignal call is invoked, to indicate a signal handler has completed. It needs to restore back on the top of the kernel stack the snapshot of the interrupt state currently on the top of the user stack.
You may build this project directly from the kernel we provide here, or merge the provided kernel with your project 1 solution. The advantage of merging is that you will get a more full-featured kernel, which may make things easier to test. For example, the Sys_PS system call will be handy for testing. We will not subtract points from your project 2 score for features implemented in project 1. In particular: