Xv6 Lab 2: System Calls
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
Update on 2022/7/14: I added the sysinfo lab. Lab 2 is now completely documented.
Update on 2022/9/14: I recently put the lab code on GitHub. If you need a reference, you can find it here:
https://github.com/ttzytt/xv6-riscv
The different branches contain the different labs.
Lab 2: system calls
The system-call process
As its name suggests, this lab asks us to add two system calls to the kernel. Before adding them, we need to understand the path taken by a system call.
First, the user-mode system-call functions are declared, but not implemented, in user/user.h.
1 | // system calls |
These functions are actually implemented in assembly in user/usys.S. The language is RISC-V assembly rather than NASM, but NASM is the only language mode that gives me satisfactory syntax highlighting:
1 | fork: |
Notice the instruction li a7, SYS_fork. The form of li, meaning load immediate, is:
li, rd, imm
It loads the immediate value imm into the rd register.[1]
In li a7, SYS_fork, SYS_fork is therefore an immediate value. It is defined in kernel/syscall.h, which is why the assembly file begins with an #include.
1 | // System call numbers |
This file assigns numbers to the different system calls; for now, call them syscall numbers. Thus, li a7, SYS_fork places the syscall number for fork in register a7. After entering the kernel, that value tells us which system call was requested.
The next assembly instruction is ecall. It is a rather remarkable RISC-V instruction that I do not fully understand, but I found some information online.[2]
The ECALL instruction atomically jumps to a controlled location, switches
spto the kernel stack, saves the old userspandpc, saves the old privilege mode, selects the new privilege mode, and sets the newpcto the kernel syscall handler.
Roughly speaking, ecall jumps to a particular address at which kernel services are located. Like an ordinary function call, it also preserves the execution state so that the system can later return to the current state after completing the call. For example, it saves the stack pointer, sp, and program counter, pc.
After ecall transfers control into the kernel, execution first reaches the kernel handler syscall().
1 | static uint64 (*syscalls[])(void) = { |
syscall() uses the number stored in a7 to invoke the corresponding service. How can it obtain a function from a syscall number? The answer is an array of function pointers.
The syntax [SYS_fork] sys_fork is a C designated initializer in which the value in brackets is used as the element index. For example, int arr[] = {[3] 2333, [6] 6666} creates an array whose element at index 3 is 2333, whose element at index 6 is 6666, and whose other elements are initialized to zero. This syntax is unavailable in C++.[3]
The actual implementations of these kernel services are not in this file; they are in kernel/sysproc.c. For example, get_pid() is implemented as:
1 | uint64 |
After the call completes, its return value is placed in register a0 when control returns to user mode. That is the purpose of p->trapframe->a0 = syscalls[num]();.
System call tracing
Implement a system call namedtracethat traces system calls made by a particular process. After a process invokestrace, it prints the system calls made by that process in a specified format. A mask argument selects which calls are traced.
More precisely, every bit of the mask represents one system call. If bit is one, syscall number must be traced.
Before implementing the behavior, we must follow the complete system-call path and “register” the new call in several files.
Registering the system call in the different files
First, declare it in the user-mode header user/user.h, allowing a user to invoke the assembly interface that enters the kernel:
1 | …… |
As explained earlier, an assembly function performs the transition. This assembly is generated automatically by the Perl script user/usys.pl, so that script must be changed.
1 | print "# generated by usys.pl - do not edit\n"; |
The next make qemu causes the added entry to produce the following in user/usys.S:
1 | .global trace |
User-mode registration is now complete. Next, register the call in the kernel.
Assign the call a number in kernel/syscall.h, so that the corresponding function can be located from that number.
1 | // System call numbers |
As introduced earlier, the kernel dispatcher syscall() uses an array of function pointers to find the required function. We must add an element to that array and declare the trace function.
In kernel/syscall.c:
1 | extern uint64 sys_chdir(void); |
A declaration such as extern uint64 sys_trace(void); belongs in kernel/syscall.c, while the implementation belongs in kernel/sysproc.c. For now, add any implementation there; the real implementation is discussed later.
1 |
|
At this point, run make qemu again and enter a trace command in the shell, such as trace 32 grep hello README. Seeing hello from trace confirms that the call has been registered successfully.
Implementation
To learn which system calls are used, we can modify the dispatcher itself because every user program must pass through it to request any kernel service. The trace information can therefore be printed directly inside this function.
However, many processes may be making system calls simultaneously. Printing unconditionally inside syscall() would report calls from every process rather than from only one.
Unconditional output would also violate the mask requirement, which specifies exactly which calls to print.
We therefore need a way to determine whether the current process wants tracing and, if it does, which system calls its mask selects. The simplest approach is to add a mask field to the structure describing a process, namely struct proc in kernel/proc.h.
1 | struct proc { |
The dispatcher now only needs to inspect the trace_mask of the process currently entering the kernel. If the process wants to trace the call it is making, the dispatcher prints the information. It will no longer print merely because some unrelated process made a call.
The modified syscall() in kernel/syscall.c follows.
1 | const static *syscall_names[] = { |
A process’s trace_mask does not appear from nowhere. It is assigned only when that process invokes the trace system call.
Consequently, sys_trace() cannot merely print hello from trace as it did in the temporary implementation. Its revised implementation is:
1 | uint64 |
The idea is simple. User mode passes a mask to trace(), and the system call copies that mask into the current struct proc. Later, when the process passes through the dispatcher, the kernel knows which calls to trace.
The expression argint(0, &mask) reads the first 32-bit argument.
We do not receive arguments using the ordinary C calling form because the kernel and user process have different page tables. Instead, system calls use the family of functions argaddr(), argint(), and argstr().[3]
These helpers ultimately call argraw(), shown below. Its argument n identifies which argument should be read.
1 | static uint64 argraw(int n) { |
It reads data from the trapframe. The trapframe preserves the context for a system call: register state at the moment of the call, the current process’s kernel-stack location, the kernel page table, and other information. After finishing the call, the kernel restores the previous state from these values. This resembles a function call; see this article for comparison.
Why does requesting argument number return the corresponding a register? I am not completely certain, but it is probably a consequence of the RISC-V calling convention, which is also discussed in this article.
Some relevant parts of GCC’s RISC-V calling convention are:[4]
- A 32-bit integer return value is placed in register a0.
- 32-bit integer arguments are placed from left to right in a0, a1, through a7. Additional arguments are pushed on the stack from right to left, with the ninth argument at the top.
This agrees quite well with argraw() and also with placing the system-call result in a0. I still do not understand why a6 cannot be used. a7 clearly cannot hold an argument because it stores the syscall number. If you know the reason for a6, please discuss it in the comments.
Entering trace 32 grep hello README now produces the correct output.
However, if you next enter grep hello README without trace, trace output still appears.
This makes sense after some thought. xv6 maintains a table containing a total of 64 processes. When a new process is created, the system assigns the first unused process slot.
The implementation appears in allocproc() in kernel/proc.c:
1 | // Look in the process table for an UNUSED proc. |
If no intervening command has run, grep hello README receives the same process slot previously used by trace 32 grep hello README.
The old process’s trace_mask was changed and never reset. The later grep hello README therefore naturally continues to print trace information.
To fix this, we need to know which function releases resources and clears information when a process ends. Adding one line there to reset trace_mask prevents tracing output from leaking into a process that never requested it.
The function that performs this final cleanup, somewhat like a C++ destructor, is freeproc(). It is located alongside allocproc() in kernel/proc.c.
Simply add p->trace_mask = 0; at the end:
1 | // free a proc structure and the data hanging from it, |
Repeating the previously failing sequence now works correctly.
Only one final step remains in this part of the lab.
The trace system call should enable tracing for the process that calls it and any children that it subsequently forks, but should not affect other processes.
In other words, if a parent process has a trace_mask, its child must inherit the same value. Every child is created by fork(), so we can modify the implementation of fork directly.
Like the preceding two process-related functions, fork() is implemented in kernel/proc.c.
The first lines define two struct proc pointers, np and p. The comments make it clear that np is the new process. We do not need to understand all the surrounding machinery; simply add np->trace_mask = p->trace_mask in the appropriate place.
That completes the feature. The supplied unit tests should now pass.
1 | fork(void) |
Sysinfo
Implement a system call that collects the system’s currently free memory and number of active processes. It accepts astruct sysinfo*, and the system call writes the information into that structure.
As before, register this call in all of the necessary files before implementing it. The procedure is identical to the one above, so I will not repeat it. The only detail is that the user-mode declaration in user/user.h must take a struct sysinfo*, rather than the integer argument used by trace.
The kernel does not provide functions that report free memory or the current process count, so we must implement them.
First, implement the free-memory function in kernel/kalloc.c, as required by the lab.
That file defines a kmem structure:
1 | struct run { |
It also contains functions such as kalloc():
1 | // Allocate one 4096-byte page of physical memory. |
From the comments, variable names, and behavior of kalloc, we can infer that kmem maintains a linked list in which every element represents an available 4 KB memory page.
We can traverse that list to calculate the free space.
1 | uint64 |
We also need the number of active processes. The lab requires this function to be implemented in kernel/proc.c.
Consider the previously discussed allocproc:
1 | // Look in the process table for an UNUSED proc. |
Following that traversal pattern, inspect every process and count those whose state is not UNUSED. This gives the number of process slots currently in use.
1 | uint |
Now that both the remaining memory and process count are available, sys_sysinfo can be implemented in kernel/sysproc.c.
As in trace, the user-mode and kernel-mode page tables differ. We obtain user arguments by consulting the register state saved in the trapframe when the user invoked the call.
This call receives a pointer to a structure, so use argaddr.
1 | uint64 |
The pointer is a virtual address interpreted using the user page table. After collecting the system information in info, we must use copyout to copy that structure to the address in the user’s page table.
The declaration of copyout is int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len).
Its source comment says:
Copy from kernel to user. Copy
lenbytes fromsrcto virtual addressdstvain a given page table. Return 0 on success and -1 on error.
The first argument is the page table in which virtual address dstva must be interpreted. Here it is the user page table, cur_proc->pagetable.
The next argument, dstva, is the copy destination. We pass usr_addr, the argument obtained from user mode through argaddr.
src is the source data, namely info, and the final argument is plainly the amount of data to copy, sizeof(info).
With these changes, the tests pass. I also wish everyone working on this lab an early AC.

Summary
This lab genuinely resolved many of my earlier questions about system calls. This course is excellent. Beforehand, I could not understand the difference between an ordinary function call and a system call. Implementing a system call required tracing its complete route and registering a new call in all the relevant files, and that process made the mechanism much clearer.






![[Stanford CS144] Lab 4 Record](/img/CS144/tcp%E7%8A%B6%E6%80%81%E6%B5%81%E8%BD%AC%E5%9B%BE.jpg)