Operating System (2026 Fall)
  • Home
  • Policies
Operating System · CS3423 · Fall 2026

xv6 schedulers

Lab created with ❤️ by:Photo of 嘉宏 Alvin嘉宏 AlvinPhoto of 又歆 Yuhsin又歆 YuhsinPhoto of 佳晉 Johnny佳晉 Johnny
Contents
  1. 0Start here
    1. Set up the container
    2. Build, run and test
    3. One branch per task
    4. Rules for every task
  2. 1Processes and their states
    1. The ready queue
  3. 2The timer interrupt
    1. Two hooks: accounting, then the preemption check
  4. 3Locks
    1. The locks in this lab
    2. Locks and interrupts
  5. 4Sleep, wakeup and exit
    1. pause()
    2. The scheduler hooks in sleep and wakeup
    3. Exit
  6. 5The context switch
    1. What sched() checks
  7. 6Scheduler hooks and fields
  1. 7Your tasks
    1. Task 1 · Round robin
    2. Task 2 · Shortest estimated time left
    3. Task 3 · Priority with aging
    4. Task 4 · First come, first served
    5. Task 5 · Multilevel feedback queue
  2. 8Reading the test output
    1. One test, tick by tick
    2. When a test fails
    3. Write your own test
  3. 9Debugging
    1. Symptoms
    2. Print from the kernel
    3. gdb: set up two terminals
    4. gdb: a first session
    5. gdb: list the processes
    6. gdb: recipes
    7. gdb: commands for this lab
  4. 10Grading and submission
    1. Implementation (3 points)
    2. Lab quiz (4 points)
    3. How to prepare for the quiz?
    4. Submit

Part 0Start here

The lab has five tasks. Each task is on its own branch of the lab repository, with its own spec. In every task you write a scheduling policy in kernel/sched.c and connect it to the timer in kernel/trap.c. Do the tasks in order: Task 5 uses the pickers that you write in Tasks 1–4.

TaskBranchSchedulerPointsPublic tests
Task 1q0Round robin105
Task 2q1Shortest estimated time left, no preemption105
Task 3q2Priority with aging, preemptive107
Task 4q3First come, first served104
Task 5q4Multilevel feedback queue with four queues6020

Deadline: 2026/11/10 23:59 (UTC+8). Upload your zip to the eLearn assignment by then (Part 10).

Set up the container

Every command in this lab runs in the course container. It has the RISC-V compiler, QEMU, gdb and the grading script. The TAs grade in the same container.

  1. Install Docker (Docker Desktop on macOS and Windows).
  2. Clone the repository and go into its folder:
    git clone https://github.com/GaGa55555LaLa/os-xv6-lab-student.git
    cd os-xv6-lab-student
  3. Build the image. You do this once:
    docker build -t xv6-lab .devcontainer
  4. Start the container in the repository folder. On macOS and Linux:
    docker run -it --rm --name xv6 \
        -u "$(id -u):$(id -g)" \
        -v "$PWD":/xv6 -w /xv6 \
        xv6-lab bash
    On Windows, in PowerShell:
    docker run -it --rm --name xv6 `
        -v "${PWD}:/xv6" -w /xv6 `
        xv6-lab bash
  • Shortcut: ./lab shell builds the image if needed and starts the container. ./lab grade and ./lab make qemu run one command in it. These work on Linux, macOS, and in Git Bash or WSL on Windows.
  • VS Code: open the folder and run "Dev Containers: Reopen in Container".
  • Leave the container with exit. Your files stay in the repository folder on your computer.
  • Windows: if ./grade prints env: $'python3\r': No such file or directory, commit your changes first, then run:
    git config core.autocrlf false
    git rm -r -q --cached .
    git reset -q --hard

Build, run and test

make qemu              # build and boot xv6 on one CPU; quit with Ctrl-a, then x
$ schedcheck           # in xv6: run every small test (mlfqcheck in Task 5)
$ schedcheck late      # run one test
$ schedtest 42         # a larger workload with seed 42, drawn as a Gantt chart

./grade                # in the container shell: build, run every test, compare
./grade late wake      # only some tests

One branch per task

git checkout q0                    # switch to Task 1
git commit -am "q0: round robin"   # commit your work on this branch
git show q0:kernel/sched.c         # read a file of another branch without switching
git show origin/q1:kernel/sched.c  # the same, for a branch you have not checked out yet

The prompt in the container shows the current branch, for example /xv6 (q0) $. Commit your work before you switch branches. The TAs grade the last commit on each branch; uncommitted changes are not graded.

Rules for every task

  • Do not change these files; they are overwritten when grading: user/ (you may add your own tests there), mkfs/, Makefile, kernel/param.h, kernel/syscall.h, kernel/schedlog.h, grade, test-xv6.py, .devcontainer/, lab and .gitattributes.
  • Do not add kernel source files.
  • Do not change, add or remove any schedlog() or logat() call. These calls record the events that the tests compare (Part 8).
  • You may use generative AI tools. You are responsible for every line you submit: the lab quiz asks you to trace the scheduler and explain your implementation. Do not share your code with other students, and do not push it to a public repository.

Part 1Processes and their states

xv6 keeps every process in a fixed array, proc[NPROC], in kernel/proc.c. Each entry is a struct proc (kernel/proc.h). The field p->state holds the state of the process. An entry whose state is UNUSED is free. allocproc() finds a free entry and gives it the next pid, so a process created later has a larger pid.

The xv6 states have different names from the ones in the lecture:

xv6 stateLecture nameMeaning
USEDNewallocproc() has reserved the entry, but the process cannot run yet.
RUNNABLEReadyThe process can run and waits for the CPU.
RUNNINGRunningThe process is on the CPU now.
SLEEPINGWaitingThe process waits for an event, such as the end of a pause().
ZOMBIETerminatedThe process has exited. Its parent has not collected it with wait() yet.

Each transition is caused by a few kernel functions and records an event in the test log (Part 8). These six paths are the Trace Code paths that the lab quiz covers.

Process statesclick an arrow or a button
NewUSED ReadyRUNNABLE RunningRUNNING TerminatedZOMBIE WaitingSLEEPING kfork sched_pick → swtch sched_timer sleep sched_wakeup kexit

The ready queue

The specs talk about a "ready queue", but xv6 has no queue data structure. The ready queue is the set of entries in proc[] whose state is RUNNABLE. In q4, queue Qk is the set of RUNNABLE entries whose level is k.

The order of a queue is not stored either. It comes from fields of struct proc, for example enter_tick (the tick at which the process joined the ready queue) and pid. A picker such as pick_rr() returns the process at the head of a queue. It calls pick_by() in kernel/sched.c, which loops over proc[] and keeps the best entry so far. Part 8 shows a queue changing tick by tick.

The lab runs xv6 on one CPU: the Makefile fixes CPUS to 1. So at most one process is RUNNING at a time.

Part 2The timer interrupt

A trap stops the instructions that the CPU is running and jumps to a handler in the kernel. A system call, an exception (such as a page fault) and a device interrupt are all traps. The timer is a device that raises interrupts at times the kernel chooses.

Every timer interrupt reaches clockintr(), which adds 1 to the global counter ticks. One tick, the time between two timer interrupts, is the unit of time in this lab. Every count in the specs, such as a time slice, an allotment or a waiting time, is a number of ticks.

The register stvec holds the address of the trap handler. xv6 changes it when the CPU moves between user mode and kernel mode. So a timer interrupt reaches clockintr() by one of two paths, depending on what the CPU was doing. A test job burns CPU time by calling the system call cputicks() in a loop, so the interrupt can find it in user mode or in the kernel.

Where was the CPU when the interrupt came?the most recent call is at the top

Two hooks: accounting, then the preemption check

The lab connects the timer to the scheduler with two functions in kernel/sched.c. You add the calls to them in kernel/trap.c (TODO (trap)).

  • sched_tick() does the accounting for the tick that has just ended. It charges the tick to the running process, adds 1 to the waiting time of every RUNNABLE process, and wakes up every pause() that is due. It is called in clockintr(), right after ticks++.
  • sched_timer() does the preemption check. It calls should_preempt(). If should_preempt() returns 0, the running process continues. Otherwise the process gives up the CPU. It is called in usertrap() and kerneltrap(), after devintr() has returned 2 (a timer interrupt).

Since clockintr() runs inside devintr(), the accounting always happens before the preemption check. Until sched_tick() is connected, ticks still grows but no pause() ever wakes up, so every test hangs. When the interrupt finds the CPU in the scheduler loop, myproc() returns 0: there is no process to check.

For the lab quiz. The Trace Code section of q4-spec.md asks how long one tick is, which line in clockintr() stops the interrupt from staying pending, and what happens when you remove that line. Find the answers in kernel/trap.c and by running the experiment. The full list of quiz topics is in Part 10.

Part 3Locks

Kernel code often reads and changes data that other kernel code also uses, such as the fields of a process. Two pieces of code must not change the same data at the same time, or one of them may see it half updated. On a computer with several CPUs, two CPUs can run kernel code at once. Even on one CPU, a timer interrupt can stop kernel code in the middle of an update and run other kernel code. A lock makes such code take turns.

xv6 uses spin locks (struct spinlock, kernel/spinlock.c). acquire(&lk) waits until no other CPU holds lk, and then holds it. release(&lk) gives it back. The code between the two calls is a critical section: while one CPU is in it, no other CPU can enter a critical section of the same lock. For example, kfork() in kernel/proc.c makes a new process runnable like this:

acquire(&np->lock);
np->state = RUNNABLE;
np->enter_tick = ticks;
release(&np->lock);

Code that reads these fields also holds np->lock. So it sees either the old values or the new ones, never a RUNNABLE process with an old enter_tick.

The locks in this lab

LockHow manyProtects
p->lockone per processthe state of the process and its scheduling fields, such as state, chan, level, enter_tick, slice_used and wait_ticks
tickslockonethe global counter ticks

The comments in struct proc (kernel/proc.h) say which lock protects each field.

Locks and interrupts

acquire() also turns interrupts off on the CPU, and they stay off until the CPU has released every lock it holds. So code that holds a spin lock cannot be interrupted by the timer. Without this, the timer handler could try to acquire a lock that the interrupted code already holds, and wait for it forever. For example, clockintr() acquires tickslock, which sys_pause() also holds while it reads ticks.

This lab runs on one CPU, but the locks still matter: the kernel checks how they are used and panics when a rule is broken. Part 5 shows the checks in sched(). Three rules follow:

  • A spin lock cannot be acquired twice. If the CPU already holds a lock, acquire() of the same lock panics with acquire. The pickers and top_level() acquire the lock of every process in turn. So do not call them while you hold any p->lock.
  • Release only a lock you hold. Otherwise release() panics with release.
  • Read and write the fields of a process while you hold its lock.

Part 4Sleep, wakeup and exit

A process that must wait for an event sleeps: it sets its state to SLEEPING and gives up the CPU. A process gives up the CPU by calling sched(); Part 5 shows what happens inside it. When the event happens, other kernel code calls wakeup(chan). The channel chan is an address that names the event.

Sleeping takes two steps:

  1. sleep_prepare(chan) stores chan in p->chan.
  2. sleep() sets the state to SLEEPING and calls sched(), but only if p->chan is still set.

wakeup(chan) clears p->chan in every process that waits on chan, and calls sched_wakeup() for each one that is already SLEEPING. The two steps prevent a lost wakeup: if the event happens between the two steps, p->chan is already cleared, so sleep() returns at once instead of sleeping forever.

pause()

pause(n) (sys_pause() in kernel/sysproc.c) sets p->wake_tick to the current tick plus n and sleeps on the channel &p->wake_tick. At every tick, sched_tick() wakes up each process whose wake_tick has come. So a process in pause(n) sleeps for n ticks and wakes up once. The test programs use pause() to stand for I/O.

The scheduler hooks in sleep and wakeup

  • sleep() calls sched_sleep(p) just before sched(). For the scheduler, this is the end of a CPU burst: the time a process runs until it blocks.
  • sched_wakeup(p) sets the state to RUNNABLE, records the tick at which the process joins the ready queue, and resets its waiting time.
  • Each test job starts with the system call schedstart(), which sleeps until the start tick of the job. When that sleep ends, sched_wakeup() records a new event instead of a wake event.

Exit

kexit() closes the files of the process, gives its children to init, wakes up its parent, sets its state to ZOMBIE, and calls sched(). It never returns. The parent's wait() later finds the ZOMBIE child and frees its entry in proc[].

Part 5The context switch

Each process has a kernel thread: a kernel stack (p->kstack) and a set of saved registers (p->context). When a process makes a system call or is interrupted, the kernel code runs on the kernel stack of that process. The CPU also has a scheduler thread, which runs scheduler() on its own stack. Its saved registers are in cpu->context.

swtch(old, new) in kernel/swtch.S saves the current registers in old and loads the registers in new, including the stack pointer sp and the return address ra. So the ret at the end of swtch returns into the other thread, at the point where that thread last called swtch.

xv6 never switches directly from one process to another. A process calls sched(), which switches to the scheduler thread. The scheduler thread selects the next process and switches to it. In the example below, process A calls pause() and goes to sleep, and process B, which slept earlier and has been woken up, runs next.

From A to B through the scheduler thread

The loop in scheduler() (kernel/proc.c) is short:

for (;;) {
  intr_on();                       // a pending interrupt is taken here,
  intr_off();                      // then interrupts are off again
  if ((p = sched_pick()) != 0) {   // returns p locked and RUNNABLE
    p->state = RUNNING;
    p->slice_used = 0;
    p->wait_ticks = 0;
    schedlog(p, SEV_RUN);
    c->proc = p;
    swtch(&c->context, &p->context);
    c->proc = 0;                     // p has left the CPU
    release(&p->lock);
  } else {
    asm volatile("wfi");            // nothing to run: wait until an interrupt is pending
  }
}

Simplified from kernel/proc.c: the variable found, the line mycpu()->intena = 0; and the comments are left out.

A process that has never run has no earlier call to sched(). allocproc() sets its context.ra to forkret, so the first swtch() to it starts forkret(), which releases p->lock and returns to user space.

What sched() checks

sched() checks four conditions before it calls swtch(), and panics if one of them fails. These panics are the most common crashes in this lab.

Panic messageMeaning
sched p->lockThe caller does not hold p->lock.
sched locksThe caller holds another lock in addition to p->lock.
sched RUNNINGp->state is still RUNNING. The caller must set the new state first.
sched interruptibleInterrupts are on.

Why is p->lock held during the switch? p->state and p->context must change together. If the lock were released before swtch(), the process would be RUNNABLE while its registers are only half saved, and another CPU could start to run it. So the lock is acquired in one thread and released in another, as the stepper shows.

Part 6Scheduler hooks and fields

The scheduling policy is in kernel/sched.c. The rest of the kernel calls it through a few functions, the hooks. The TODO comments in kernel/sched.c and kernel/trap.c on each branch mark exactly what to change. In every task you also add the calls to sched_tick() and sched_timer() in kernel/trap.c (TODO (trap)).

HookCalled byWhenLocks held on entryYou change it in
sched_pick()scheduler()The CPU needs a process to run.none; returns the process with its lock heldTask 5
sched_sleep(p)sleep()p blocks: its CPU burst ends.p->lockTask 5
sched_wakeup(p)wakeup(), sched_tick(), kkill()p goes from SLEEPING to RUNNABLE.p->lockTask 5
sched_tick()clockintr() (TODO trap)Every tick, after ticks++.none; locks each process in turnTask 5 (also charge(), which it calls)
sched_timer()usertrap(), kerneltrap() (TODO trap)A timer interrupt while a process runs.noneevery task (TODO (yield))
should_preempt(p)sched_timer()Same as above.noneevery task
pick_rr(), pick_shortest(), pick_priority(), pick_fifo()sched_pick(), should_preempt()Find the head of a queue.none; return the process unlockedone picker in each of Tasks 1–4; all four are copied into Task 5

A picker returns its result without the lock. Before it uses the result, the caller must lock the process and check that its state is still RUNNABLE, as sched_pick() does.

The lab adds these fields to struct proc. All of them are protected by p->lock.

FieldMeaningChanged by the provided code
levelThe queue of the process (q4; always 0 in q0–q3).allocproc(), schedstart()
priority0 (highest) to 9 (lowest). It never changes.allocproc(), schedstart()
burst_estEstimated length of the current CPU burst, in ticks.allocproc(), schedstart()
cur_burstTicks run in the current CPU burst.+1 in sched_tick(); 0 in sched_sleep()
slice_usedTicks run since the process was last picked.+1 in sched_tick(); 0 in scheduler()
allot_usedTicks run in the current queue during this burst (q4).allocproc(), schedstart()
wait_ticksTicks spent RUNNABLE since the process last ran.+1 in sched_tick(); 0 in scheduler() and sched_wakeup()
enter_tickThe tick at which the process joined the ready queue.userinit(), kfork(), sched_wakeup(), schedstart()
rticksTotal ticks run. The system call cputicks() returns it.+1 in sched_tick()
demotedThe process moved down a queue during the last tick (q4).allocproc()

Part 7Your tasks

Before you write code for a task, answer its Think first questions. Open Where to look if you are stuck.

Task 1

Round robin

branch q0 q0-spec.md 10 points · 5 public tests

Processes take turns. Each one runs for at most a short, fixed time, then goes to the end of the ready queue, and the next one gets the CPU.

Rules

  • The process that joined the ready queue earliest runs next. If several joined in the same tick, the smaller pid goes first.
  • The time slice is RR_SLICE ticks (2, in kernel/param.h).
  • At each timer interrupt, after the accounting, the running process p does this:
CaseResult
p has not used up its time slicekeep running
p has used up its time slice, and another process is RUNNABLEgive up the CPU, go to the end of the ready queue
p has used up its time slice, and no other process is RUNNABLEkeep running, and start a new time slice
  • Going to the end of the ready queue counts as joining it again now.
  • In the last case, nothing else starts the new time slice; should_preempt() must do it.
  • A process that blocks (for example in pause()) gives up the CPU by itself. You do not handle that.

Already provided

  • A new process joins the end of the ready queue.
  • At every tick that TODO (trap) reports, the time slice of the running process grows, and so does the waiting time of every RUNNABLE process. A pause() that is due wakes up.
  • A process that wakes up joins the end of the ready queue.
  • A process that is picked starts a new time slice.

What you should implement

  1. TODO (trap) in kernel/trap.c: tell the scheduler about every tick, and on a timer interrupt let sched_timer() check whether the running process gives up the CPU. Do the accounting first, then the check. A timer interrupt in the kernel may come when no process is running. Do this first. Until it is done, pause() never wakes up and every test hangs.
  2. TODO (q0) in kernel/sched.c: pick_rr() chooses the next process to run, and should_preempt() decides whether the running process must give up the CPU.
  3. TODO (yield) in kernel/sched.c: when sched_timer() gives up the CPU, put the process back at the end of the ready queue and switch to the scheduler.

TODO (trap) and TODO (yield) are the same in every task. You can copy them to the later tasks.

The code

kernel/trap.cLines 165 to 174 in q0
165void
166clockintr()
167{
168 if (cpuid() == 0) {
169 acquire(&tickslock);
170 ticks++;
171 wakeup(&ticks);
172 release(&tickslock);
173 // TODO (trap): tell the scheduler that a tick has passed.
174 }
kernel/trap.cLines 81 to 87 in q0
81 if (killed(p))
82 kexit(-1);
83
84 // TODO (trap): on a timer interrupt (which_dev == 2), let the
85 // scheduler decide whether to give up the CPU.
86
87 prepare_return();
kernel/trap.cLines 148 to 161 in q0
148 if ((which_dev = devintr()) == 0) {
149 // interrupt or trap from an unknown source
150 printk("scause=0x%lx sepc=0x%lx stval=0x%lx\n", scause, r_sepc(),
151 r_stval());
152 panic("kerneltrap");
153 }
154
155 // TODO (trap): on a timer interrupt (which_dev == 2), let the
156 // scheduler decide whether to give up the CPU. There may be no
157 // current process: the CPU can be in the scheduler loop.
158
159 // the scheduler may have caused some traps to occur,
160 // so restore trap registers for use by kernelvec.S's sepc instruction.
161 w_sepc(sepc);
kernel/sched.cLines 108 to 115 in q0
108// In ready-queue order; the running process gives up the CPU when its
109// time slice is used up (see should_preempt()).
110struct proc *
111pick_rr(int level)
112{
113 // TODO (q0): the RUNNABLE process in queue level that runs next.
114 return 0;
115}
kernel/sched.cLines 201 to 227 in q0
201#define PREEMPT_REQUEUE 2 // back to the end of the ready queue
202
203// Should the running process p give up the CPU at this timer interrupt?
204static int
205should_preempt(struct proc *p)
206{
207 // TODO (q0): must p give up the CPU now? Return 0 to keep
208 // running, or PREEMPT_REQUEUE.
209 return 0;
210}
211
212// Called from usertrap() and kerneltrap() on a timer interrupt.
213void
214sched_timer(void)
215{
216 struct proc *p = myproc();
217 int r = should_preempt(p);
218
219 if (r == 0)
220 return;
221 acquire(&p->lock);
222 // TODO (yield): p gives up the CPU. Put it back in the ready queue,
223 // at the end of the queue.
224 schedlog(p, r == PREEMPT_REQUEUE ? SEV_REQUEUE : SEV_PREEMPT);
225 // TODO (yield): switch to the scheduler.
226 release(&p->lock);
227}

Think first

  1. In kerneltrap(), what does myproc() return when the interrupt came while the CPU was in the scheduler loop? What would sched_timer() do with that value?
    Where to look

    The first line of sched_timer() above, and the tab "In the scheduler loop" in Part 2.

  2. Why must the accounting come before the preemption check? If the check came first, what would slice_used show for the tick that has just ended?
    Where to look

    charge() in kernel/sched.c, and "Two hooks" in Part 2.

  3. The order of the ready queue comes from two fields of struct proc. Which ones? Write before(a, b) on paper. Check the case where a and b joined in the same tick: does before(b, a) give the opposite answer?
    Where to look

    "The ready queue" in Part 1, the field table in Part 6, and the loop in pick_by().

  4. How can should_preempt() find out whether another process is RUNNABLE, using only code that already exists?
    Where to look

    Read what pick_by() returns and which states it accepts. What is p's state while should_preempt() runs?

  5. Which fields of p does should_preempt() read and write, and which lock protects them? May you still hold that lock when you call pick_rr()?
    Where to look

    "Locks and interrupts" in Part 3. The gdb recipe "The kernel panics" in Part 9 shows what happens otherwise.

  6. In the last row of the table, p keeps running with a new time slice. Which field counts the time slice? Where does the provided code set it to 0, and why does that code not run in this case?
    Where to look

    The loop in scheduler() in Part 5.

  7. TODO (yield): what must be true about p before sched() may switch away from it? And what makes p count as joining the end of the ready queue?
    Where to look

    The four checks in sched() (Part 5), and how the order of the ready queue is decided (Part 1). Another function in kernel/proc.c also gives up the CPU; reading it may help.

Tests

TestWhat it checks
turnsjobs in the ready queue take turns, RR_SLICE ticks each
alonea job alone keeps running after its time slice
latea job that arrives later joins the end of the queue (Part 8 walks through it)
wakea job that wakes up joins the end of the queue
slicea job alone starts a new time slice when its slice is used up
Task 2

Shortest estimated time left

branch q1 q1-spec.md 10 points · 5 public tests

Run the process that is expected to finish its CPU burst soonest. The running process keeps the CPU until it blocks or exits.

Rules

  • A CPU burst is the time a process runs until it blocks. Each process starts with an estimate of its CPU burst, in ticks (the burst_est argument of schedstart()). In this task the estimate never changes.
  • Estimated time left = the estimate minus the ticks the process has run in its current burst. A process that wakes up starts a new burst, so its time left is the whole estimate again. Since nothing is preempted in this task, every RUNNABLE process has its whole estimate left; the formula matters in Task 5.
  • The RUNNABLE process with the least estimated time left runs next. If several are equal, the smaller pid goes first, even if another one has been waiting longer.
  • At a timer interrupt, the running process always keeps running. It gives up the CPU only when it blocks or exits, and that happens by itself.

Already provided

  • A new process, and a process that wakes up, becomes RUNNABLE.
  • At every tick that TODO (trap) reports, the ticks of the running process in its current burst grow, and a pause() that is due wakes up.
  • When a process blocks, its current burst ends.

What you should implement

  1. TODO (q1): pick_shortest() and should_preempt().
  2. TODO (trap) and TODO (yield): copy them from q0. Nothing is preempted in this task, so its tests never run your TODO (yield) code; Task 1 tests it.

The code

kernel/sched.cLines 108 to 114 in q1
108// The process with the least estimated time left in its CPU burst.
109struct proc *
110pick_shortest(int level)
111{
112 // TODO (q1): the RUNNABLE process in queue level that runs next.
113 return 0;
114}
kernel/sched.cLines 167 to 175 in q1
167// Charge the tick that just ended to a RUNNING process.
168static void
169charge(struct proc *p)
170{
171 p->rticks++;
172 p->slice_used++;
173 p->cur_burst++;
174 logat(p, SEV_TICK, ticks - 1);
175}

Think first

  1. Which two fields give the estimated time left? Write the formula with them, even though it always equals the whole estimate in this task. Task 5 needs the general form.
    Where to look

    The field table in Part 6, and charge() above.

  2. cur_burst and rticks both count ticks. When is each one set back to 0? Which one means "the ticks the process has run in its current burst"?
    Where to look

    charge() above, and sched_sleep(), schedstart() and allocproc() in the kernel.

  3. Equal time left goes to the smaller pid, "even if another one has been waiting longer". Which field must your before() not look at? Which test checks this?
  4. Nothing is preempted in this task. Which events can never appear in a q1 log?

Tests

TestWhat it checks
shortestthe job with the least estimated time left runs first
no-preempta running job is not interrupted by a shorter one
tieequal estimates: the smaller pid runs first
tie-arrivalequal estimates: the smaller pid runs first, not the earlier arrival
wakea job back from sleep is compared by its estimate again
Task 3

Priority with aging

branch q2 q2-spec.md 10 points · 7 public tests

Always run the most important process. A process that waits long enough becomes more important, so every process gets to run eventually.

Rules

  • Each process has a priority from 0 to 9 (the priority argument of schedstart()). 0 is the highest. The priority itself never changes.
  • Effective priority = max(0, priority - waiting time / PRIO_AGE_STEP), with integer division. PRIO_AGE_STEP is 3 (kernel/param.h). So every 3 ticks of waiting make a process one step more important.
  • Process a comes before process b if a has the higher effective priority (the smaller number), or the same effective priority and the smaller pid. How long a process has waited matters only through its effective priority, not as a tie-break.
  • The RUNNABLE process that comes before all the others runs next.
  • At each timer interrupt, after the accounting: if some RUNNABLE process comes before the running process p, p gives up the CPU. Otherwise p keeps running. This includes the case where the other process has the same effective priority and a smaller pid.

Already provided

  • A new process, and a process that wakes up, becomes RUNNABLE with a waiting time of 0.
  • At every tick that TODO (trap) reports, the waiting time of every RUNNABLE process grows by 1, and a pause() that is due wakes up.
  • A process that is picked has its waiting time set to 0. It does not grow while the process runs.

What you should implement

  1. TODO (q2): pick_priority() and should_preempt().
  2. TODO (trap) and TODO (yield): copy them from q0. The order here ignores when a process joined, so this task's tests do not check that TODO (yield) puts the process at the end of the queue; Task 1 tests it.

The code

kernel/sched.cLines 108 to 114 in q2
108// The process with the highest priority after aging.
109struct proc *
110pick_priority(int level)
111{
112 // TODO (q2): the RUNNABLE process in queue level that runs next.
113 return 0;
114}
kernel/sched.cLines 202 to 209 in q2
202// Should the running process p give up the CPU at this timer interrupt?
203static int
204should_preempt(struct proc *p)
205{
206 // TODO (q2): must p give up the CPU now? Return 0 to keep
207 // running, or PREEMPT_REQUEUE.
208 return 0;
209}

Think first

  1. Work out the effective priority of a process with priority 5 after 0, 2, 3, 6 and 20 ticks of waiting.
  2. How can should_preempt() decide whether some RUNNABLE process comes before p? How much work does your approach do?
    Where to look

    Which RUNNABLE process is the hardest one for p to beat?

  3. should_preempt() reads p's fields and also needs information about other processes. Which locks may it hold at each step?
    Where to look

    "Locks and interrupts" in Part 3.

  4. Equal effective priority and a smaller pid also takes the CPU. Before you run the tests age-tie-preempt and age-pid-not-wait, find their jobs in user/schedcheck.c and work out by hand at which tick each job's effective priority changes.
    Where to look

    "Write your own test" in Part 8 explains the format of a test.

Tests

TestWhat it checks
prioritythe highest priority (smallest number) runs first
preempta job with a higher priority takes the CPU at once
agingwaiting raises priority, so a low-priority job gets to run
tieequal priority: the smaller pid runs first
tie-arrivalequal priority: the smaller pid runs first, not the earlier arrival
age-tie-preemptaging into an equal priority preempts a larger pid
age-pid-not-waiton an aged tie, smaller pid beats longer waiting time
Task 4

First come, first served

branch q3 q3-spec.md 10 points · 4 public tests

Run processes in the order they become ready. The running process keeps the CPU until it blocks or exits.

Rules

  • The process that joined the ready queue earliest runs next. If several joined in the same tick, the smaller pid goes first.
  • At a timer interrupt, the running process always keeps running. It gives up the CPU only when it blocks or exits, and that happens by itself.

Already provided

  • A new process joins the end of the ready queue.
  • At every tick that TODO (trap) reports, a pause() that is due wakes up.
  • A process that wakes up joins the end of the ready queue.

What you should implement

  1. TODO (q3): pick_fifo() and should_preempt().
  2. TODO (trap) and TODO (yield): copy them from q0. Nothing is preempted in this task, so its tests never run your TODO (yield) code.

The code

kernel/sched.cLines 108 to 114 in q3
108// The process that has been in the ready queue the longest.
109struct proc *
110pick_fifo(int level)
111{
112 // TODO (q3): the RUNNABLE process in queue level that runs next.
113 return 0;
114}

Think first

  1. Compare these rules with Task 1. Which part of your q0 code answers the same question, and which part does not apply here?
  2. "A process that wakes up joins the end of the ready queue" is already true. Which provided function makes it true, and which field does it set?
    Where to look

    "The scheduler hooks in sleep and wakeup" in Part 4.

  3. In the test no-preempt, what would the log show if should_preempt() still gave up the CPU after 2 ticks, as in Task 1?

Tests

TestWhat it checks
arrivaljobs run in the order they arrive
no-preempta running job keeps the CPU until it blocks or exits
wakea job that wakes up joins the end of the queue
tiesame arrival time: the smaller pid runs first
Task 5

Multilevel feedback queue

branch q4 q4-spec.md 60 points · 20 public tests

Combine the pickers from Tasks 1–4 into a scheduler with four queues. Processes move down when they use a lot of CPU, and up when they sleep or wait too long.

Rules: the queues

  • Q0 is the highest queue and Q3 the lowest. A RUNNABLE process with level == k is in Qk.
  • While a higher queue is not empty, no process from a lower queue may run.
QueuePickTiesTime sliceAllotment
Q0the earliest to joinsmaller pid1 tick2 ticks
Q1the least estimated remaining burstsmaller pid—4 ticks
Q2the highest effective priority (smallest number)smaller pid2 ticks8 ticks
Q3the earliest to joinsmaller pid—unlimited
  • Allotment: the most ticks one CPU burst (running until it blocks) may use in one queue. It starts over in a new queue.
  • Estimated remaining burst: the estimated burst minus the ticks this burst has run.
  • Effective priority: max(0, priority - wait_ticks / MLFQ_AGE_STEP), with integer division. priority is 0–9; 0 is the highest.
  • The parameters are the MLFQ_* constants in kernel/param.h. Do not change them.
kernel/param.hLines 20 to 27 in q4
20#define MLFQ_NLEVEL 4
21#define MLFQ_SLICE0 1 // L0 time slice
22#define MLFQ_SLICE2 2 // L2 re-checks priorities after this many ticks
23#define MLFQ_ALLOT0 2 // CPU ticks one burst may use in L0 before demotion
24#define MLFQ_ALLOT1 4
25#define MLFQ_ALLOT2 8
26#define MLFQ_AGE_STEP 2 // L2: +1 priority per this many waiting ticks
27#define MLFQ_STARVE 20 // L3: promote to L2 after waiting this many ticks

Rules: every tick

  • The running process uses 1 tick of its allotment. If the allotment is used up and the process is not in Q3, it moves one queue down, to the end of that queue, and counts as "just demoted" for this tick.
  • A RUNNABLE process that has waited at least 20 ticks in Q3 moves to the end of Q2. Its waiting time is set to 0, and its used allotment starts over.

Rules: blocking and waking up

  • When a process blocks: new estimated burst = (ticks this burst actually ran + old estimated burst) / 2, with integer division. The used allotment starts over.
  • Being preempted does not end a burst.
  • A process that wakes up moves up one queue, unless it is already in Q0.

Rules: preemption

After the accounting, the running process p is handled by this table. top is the highest queue that holds a RUNNABLE process.

CaseResult
top is higher than p, and p is not just demotedgive up the CPU, keep its place
top is higher than p, and p is just demotedgive up the CPU, go to the end
top is p's queue, and p is just demotedgive up the CPU, go to the end
p is in Q0, Q0 has another process, and the time slice is used upgive up the CPU, go to the end
p is in Q1, and Q1 has a process that comes before pgive up the CPU, go to the end
p is in Q2, the time slice is used up, and Q2 has a process that comes before pgive up the CPU, go to the end
otherwise (Q3 is never preempted from within Q3)keep running
  • "Comes before" uses the Pick and Ties columns, so an equal-priority process with a smaller pid counts.
  • Keep its place: the process goes back to the queue at its old position; it does not count as joining again.
  • Time slice used up but the process keeps running: start a new time slice.

Already provided

  • pick_by(level, before), and top_level(): the highest queue that holds a RUNNABLE process.
  • A new process joins the end of Q0.
  • At every tick, the burst and the time slice of the running process grow, and so does the waiting time of every RUNNABLE process. A pause() that is due wakes up.
  • A process that wakes up joins the end of its queue, and its waiting time is set to 0.
  • A process that is picked starts a new time slice, and its waiting time is set to 0.

What you should implement

  1. TODO (q0)–TODO (q3): copy pick_rr(), pick_shortest(), pick_priority(), pick_fifo() and their helpers from q0–q3. The priority picker ages with MLFQ_AGE_STEP instead of PRIO_AGE_STEP.
  2. TODO (trap) in kernel/trap.c: copy it. On every timer interrupt, do the accounting first, then the check.
  3. TODO (yield): copy it, but also handle PREEMPT_KEEP: the process keeps its place (see Preemption).
  4. TODO (MLFQ): the rules above.

mlfqcheck replaces schedcheck in this task.

The code

kernel/sched.cLines 148 to 166 in q4
148// Choose the next process to run. Returns it locked and RUNNABLE, or 0.
149struct proc *
150sched_pick(void)
151{
152 struct proc *p;
153
154 for (;;) {
155 // TODO (MLFQ): look at the queues from Q0 to Q3 and take the first
156 // process found: pick_rr() for Q0, pick_shortest() for Q1,
157 // pick_priority() for Q2 and pick_fifo() for Q3. For now, only Q0.
158 p = pick_rr(0);
159 if (p == 0)
160 return 0;
161 acquire(&p->lock);
162 if (p->state == RUNNABLE)
163 return p;
164 release(&p->lock);
165 }
166}
kernel/sched.cLines 172 to 182 in q4
172// p is about to block: its CPU burst is over.
173void
174sched_sleep(struct proc *p)
175{
176 if (p->starting)
177 return;
178 // TODO (MLFQ): update p->burst_est and reset the allotment p has used
179 // in its queue (see "Blocking" in the spec).
180 p->cur_burst = 0;
181 schedlog(p, SEV_SLEEP);
182}
kernel/sched.cLines 184 to 200 in q4
184// Make a SLEEPING process RUNNABLE.
185void
186sched_wakeup(struct proc *p)
187{
188 p->state = RUNNABLE;
189 p->enter_tick = ticks;
190 p->wait_ticks = 0;
191 if (p->starting) {
192 p->starting = 0;
193 p->logenabled = 1;
194 schedlog(p, SEV_NEW);
195 return;
196 }
197 // TODO (MLFQ): coming back from sleep moves p up (see "Waking up" in
198 // the spec).
199 schedlog(p, SEV_WAKE);
200}
kernel/sched.cLines 206 to 220 in q4
206// Charge the tick that just ended to a RUNNING process.
207static void
208charge(struct proc *p)
209{
210 p->rticks++;
211 p->slice_used++;
212 p->cur_burst++;
213 logat(p, SEV_TICK, ticks - 1);
214 // TODO (MLFQ): count this tick against the allotment of p's queue.
215 if (0 /* TODO (MLFQ): p has used up its allotment and is not in Q3 */) {
216 // TODO (MLFQ): move p one queue down, to the end of that queue, and
217 // remember that p was just demoted.
218 schedlog(p, SEV_DEMOTE);
219 }
220}
kernel/sched.cLines 222 to 247 in q4
222// Called by clockintr() on CPU 0 right after ticks++.
223void
224sched_tick(void)
225{
226 struct proc *p;
227
228 for (p = proc; p < &proc[NPROC]; p++) {
229 acquire(&p->lock);
230 if (p->state == RUNNING) {
231 charge(p);
232 } else if (p->state == RUNNABLE) {
233 p->wait_ticks++;
234 if (0 /* TODO (MLFQ): p has waited too long in Q3 */) {
235 // TODO (MLFQ): move p up to the end of Q2.
236 schedlog(p, SEV_PROMOTE);
237 }
238 }
239 // wake up pause() and schedstart() sleepers whose time has come.
240 if (p->chan == &p->wake_tick && ticks >= p->wake_tick) {
241 p->chan = 0;
242 if (p->state == SLEEPING)
243 sched_wakeup(p);
244 }
245 release(&p->lock);
246 }
247}
kernel/sched.cLines 249 to 296 in q4
249#define PREEMPT_KEEP 1 // back to the ready queue, keeping its place
250#define PREEMPT_REQUEUE 2 // back to the end of the ready queue
251
252// Highest (smallest) queue holding a RUNNABLE process, or MLFQ_NLEVEL.
253static __attribute__((unused)) int
254top_level(void)
255{
256 struct proc *p;
257 int top = MLFQ_NLEVEL;
258
259 for (p = proc; p < &proc[NPROC]; p++) {
260 acquire(&p->lock);
261 if (p->state == RUNNABLE && p->level < top)
262 top = p->level;
263 release(&p->lock);
264 }
265 return top;
266}
267
268// Should the running process p give up the CPU at this timer interrupt?
269// Returns 0 (keep running), PREEMPT_KEEP or PREEMPT_REQUEUE.
270static int
271should_preempt(struct proc *p)
272{
273 // TODO (MLFQ): follow the "Preemption" table in the spec. Also start a
274 // new time slice when p's slice is used up but p keeps running, and
275 // forget that p was just demoted. Hold p->lock while you use p's fields, but not
276 // while you call top_level() or a picker: they lock every process.
277 return 0;
278}
279
280// Called from usertrap() and kerneltrap() on a timer interrupt.
281void
282sched_timer(void)
283{
284 struct proc *p = myproc();
285 int r = should_preempt(p);
286
287 if (r == 0)
288 return;
289 acquire(&p->lock);
290 // TODO (yield): p gives up the CPU. Put it back in the ready queue:
291 // at the end of its queue for PREEMPT_REQUEUE, keeping its place for
292 // PREEMPT_KEEP.
293 schedlog(p, r == PREEMPT_REQUEUE ? SEV_REQUEUE : SEV_PREEMPT);
294 // TODO (yield): switch to the scheduler.
295 release(&p->lock);
296}

Think first

  1. sched_pick() now looks only at Q0. How should it choose among the four queues? Why does it lock the result and check its state again?
    Where to look

    The TODO comment in sched_pick(), and the note under the hook table in Part 6.

  2. Two counters limit how long p runs: the time slice and the allotment. Which field counts each one? When does each start over? Which queues have no time slice?
  3. A process can be demoted in the middle of the accounting. Afterwards it must be at the end of its new queue, and the preemption check must know that it was "just demoted". What has to change, and when does "just demoted" stop being true?
    Where to look

    The comments in charge() and in should_preempt() above, and "The ready queue" in Part 1.

  4. The new estimate uses "ticks this burst actually ran". Which counter holds exactly that number?
    Where to look

    Task 2, question 2.

  5. PREEMPT_KEEP and PREEMPT_REQUEUE both put p back in its queue. What makes the difference between keeping its place and going to the end?
    Where to look

    Where does the order of Q0 and Q3 come from? See "The ready queue" in Part 1.

  6. Go through the preemption table row by row. What does each row need to know, and where can your code get it?
    Where to look

    The functions under "Already provided" above, and the pickers you copied.

  7. should_preempt() must hold p's lock while it uses p's fields, but not while it calls top_level() or a picker. Plan the order of your reads and calls. Can another process run between them?
    Where to look

    "Locks and interrupts" in Part 3, and where sched_timer() is called in Part 2.

  8. When a Q3 process moves up to Q2 because it waited too long, which counters start over? Which ones keep their values?
  9. After all 20 tests pass, run schedtest 7 and schedtest 42. Pick a few columns of the Gantt chart and check them against the rules by hand. The hidden seeds use larger workloads like these.
    Where to look

    The section schedtest output of q4-spec.md explains the chart.

Tests

The tests are grouped by the rule they mainly check; most also depend on how each queue picks. If several tests fail, start with the rule they have in common.

RuleTests
Q0: pick and time slicerr
Q1: pick, ties and preemptionshortest, shortest-tie, shortest-preempt
Estimated remaining burst; preemption does not end a burstprogress, remaining-history, preempt-estimate
New estimate after blockingestimate, estimate-history
Q2: priority, aging, ties and time slicepriority, priority-tie, priority-pid-not-wait, priority-age-tie, slice
Q3: order; keep its place when preemptedfifo
A higher queue preemptspreempt
Allotment and demotiondemote
Waiting too long in Q3starve
Waking upwake, wake-tail

Part 8Reading the test output

schedcheck (mlfqcheck in q4) runs small fixed tests. Each test starts a few jobs, named A, B, C, … in the order of the test, and prints one line per scheduler event:

ev <tick> <job> <event> L<level>

<tick> counts from the start of the test. <level> is the queue of the job after the event; for a tick event, it is the queue the job ran in. The jobs are created in the order A, B, C, so A has the smallest pid. ./grade compares these lines with the expected ones.

EventRecorded byMeaning
newsched_wakeup(), or schedstart() if the start tick has already passedThe job leaves schedstart() and joins the ready queue for the first time.
runscheduler()The job is picked and starts to run.
ticksched_tick()The job was RUNNING when the tick ended. The whole tick is charged to it, even if it started partway through the tick.
requeuesched_timer()The job gives up the CPU and goes to the end of its queue.
preemptsched_timer()The job gives up the CPU and keeps its place in its queue (q4).
sleepsched_sleep()The job blocks.
wakesched_wakeup()The job wakes up and joins the ready queue again.
demote, promotesched_tick()The job moves down or up one queue (q4).
exitkexit()The job exits.

One test, tick by tick

This is the expected log of the q0 test late, one timer interrupt at a time. The rule is round robin with a time slice of 2 ticks (Task 1). Jobs A and B arrive at tick 0 and need 6 ticks of CPU each. Job C arrives at tick 3 and needs 2 ticks.

Test late (q0)
charged this tick (a tick line)running now, or ran without being charged a tickRUNNABLESLEEPING✕ exit
Jobs after this interrupt
New log lines in this step

    Two details of the log are easy to misread:

    • A tick event carries the tick that has just ended. sched_tick() records it at the next interrupt, as ticks - 1. Other events carry the tick in which they happen. So the lines from one interrupt can carry two different numbers, and a later line can show a smaller tick. For example, a q2 test prints ev 5 A new before ev 4 C tick. Both come from the interrupt that starts tick 5.
    • The events that sched_tick() records at one interrupt follow the order of proc[]. sched_tick() visits the entries of proc[] from first to last. A requeue or preempt line, and the run line that follows it, always come after them. At tick 3 in the stepper, B's entry comes before C's, so B's tick line comes before C's new line.

    A job whose last tick of work ends at an interrupt still needs a little CPU time to return to user space and call exit(). This takes much less than one tick, so no tick event comes before its exit event. If the job gave up the CPU at that interrupt, it must be picked again first. Its run and exit events then carry the same tick, as for C at tick 12.

    schedtest draws a larger workload as a Gantt chart with one row per queue. The section schedtest output of q4-spec.md explains its format.

    When a test fails

    When a test fails, ./grade prints the first line where your log differs from the expected log, with the three expected lines before it. For example, with a bug in TODO (yield):

    late: FAIL (0/20)  -- a job that arrives later joins the end of the queue
        first difference at event 6:
          ev 0 A tick L0
          ev 1 A tick L0
          ev 2 A requeue L0
        expected: ev 2 B run L0
        got:      ev 2 A run L0

    Events are counted from 0, so event 6 is the seventh ev line of the test. The whole output of the last run is in the file grade.out. The expected lines are in the EXPECTED block near the top of grade.

    The viewer draws a log as a chart. Paste your output into the first box and, to compare, the expected lines from grade into the second; the quotes and commas are fine. It starts with the output of the buggy kernel above: A gets the CPU back right after each requeue, and B waits until A has exited.

    Log viewer

    Write your own test

    The public tests are small. The hidden seeds use larger workloads with more ties and more blocking. To check a case that the public tests do not cover, add your own test to user/schedcheck.c (user/mlfqcheck.c in Task 5). Each test is one entry in the table tests[]:

    user/schedcheck.cLines 17 to 31 in q0
    17#define B(n) { OP_BURN, n }
    18#define P(n) { OP_PAUSE, n }
    19
    20// job: profile, arrive, level, priority, burst_est, nops, ops
    21static struct test tests[] = {
    22 { "turns", "jobs in the ready queue take turns, RR_SLICE ticks each",
    23 3, { { "a", 0, 0, 5, 2, 1, { B(4) } },
    24 { "b", 0, 0, 5, 2, 1, { B(4) } },
    25 { "c", 0, 0, 5, 2, 1, { B(4) } } } },
    26 { "alone", "a job alone keeps running after its time slice",
    27 1, { { "a", 0, 0, 5, 2, 1, { B(5) } } } },
    28 { "late", "a job that arrives later joins the end of the queue",
    29 3, { { "a", 0, 0, 5, 2, 1, { B(6) } },
    30 { "b", 0, 0, 5, 2, 1, { B(6) } },
    31 { "c", 3, 0, 5, 2, 1, { B(2) } } } },

    An entry has a name, a description, the number of jobs (at most 4), and the jobs. Each job is { profile, arrive, level, priority, burst_est, nops, ops }:

    • profile is a name for the job. The log calls the jobs A, B, C, D in the order of the entry.
    • arrive is the tick, counted from the start of the test, at which the job leaves schedstart() and joins the ready queue.
    • level, priority and burst_est are the starting values of these fields. level must be 0 in Tasks 1–4.
    • ops lists what the job does, and nops is the length of the list. B(n) runs on the CPU for n ticks; P(n) calls pause(n).

    Run it in xv6 with schedcheck and its name, and check the events against the rules by hand. Files in user/ are overwritten when grading, so your tests do not affect your grade.

    Part 9Debugging

    Symptoms

    What you seeLikely cause
    ./grade: "xv6 did not reach the shell"sched_pick() never returns a process. The first process, init, cannot run.
    A test hangs, or pause() never returnssched_tick() is not called, so no pause() wakes up.
    Each job runs until it blocks or exits; no requeue eventssched_timer() is not called, or should_preempt() always returns 0.
    panic: acquireA lock is acquired while the CPU already holds it, often a p->lock held around a picker.
    panic: sched ...See the table in Part 5.
    A wrong event orderCompare the logs in the log viewer, then find the rule in the task box that decides the first differing line.

    Print from the kernel

    In the kernel, the print function is printk(). It takes a format string like printf(), and its output goes to the console. Remove your printk() calls before you run ./grade: kernel output mixes with the event lines that ./grade reads. Press Ctrl-p in the xv6 console to print the list of processes with their pids and states.

    gdb: set up two terminals

    gdb lets you stop the kernel at any function, look at the call chain and the fields of every process, and stop when a field changes. In the container, start gdb with gdb-multiarch, not gdb. The command gdb there debugs only programs for the CPU of your own computer (x86 or ARM) and cannot read the RISC-V kernel, although make qemu-gdb tells you to run gdb.

    You need two terminals in the same container, one for xv6 and one for gdb. How to open the second one depends on how you started the container:

    You started the container withOpen a second terminal with
    VS Code, "Reopen in Container"Terminal → Split Terminal. Both terminals are in the container.
    docker run ... --name xv6 ... (from the spec)A new terminal on your computer, then docker exec -it -w /xv6 xv6 bash
    ./lab shelltmux inside the container (below)

    tmux splits one terminal into panes. Run tmux, then press Ctrl-b and then % to split the window into a left and a right pane. Press Ctrl-b and then an arrow key to move between panes. Press Ctrl-b and then [ to scroll back with the arrow keys, and q to stop scrolling. Type exit in a pane to close it.

    /xv6 (q0) $ make qemu-gdb *** Now run 'gdb' in another window. qemu-system-riscv64 -machine virt ... ... -S -gdb tcp::26002 xv6 kernel is booting init: starting sh $ schedcheck late test late: a job that arrives later ...
    /xv6 (q0) $ gdb-multiarch ... The target architecture is set to "riscv:rv64". 0x0000000000001000 in ?? () (gdb) continue Continuing. ^C Program received signal SIGINT, Interrupt. scheduler () at kernel/proc.c:457 457 intr_on(); (gdb) █
    [0] 0:bash*
    Left pane: xv6 under QEMU, started by make qemu-gdb. Right pane: gdb, connected to QEMU. Type xv6 commands on the left and gdb commands on the right.

    gdb: a first session

    1. In the first pane, run make qemu-gdb. QEMU starts xv6 and stops it before the first instruction. It waits for gdb.
    2. In the second pane, in the same folder, run gdb-multiarch. gdb reads the file .gdbinit that make qemu-gdb has written, loads the kernel symbols and connects to QEMU. It stops at address 0x1000, the first instruction.
    3. Type continue in gdb. xv6 boots in the first pane.
    4. Run a test in the first pane, for example schedcheck late. Press Ctrl-c in gdb to stop xv6 at any time, and type continue to go on.

    A session on q0 with a working solution (your line numbers will differ):

    (gdb) break sched_timer
    Breakpoint 1 at 0x80002760: file kernel/sched.c, line 232.
    (gdb) continue
    Continuing.
    
    Breakpoint 1, sched_timer () at kernel/sched.c:232
    232 {
    (gdb) bt                                  # the call chain: the newest call first
    #0  sched_timer () at kernel/sched.c:232
    #1  0x0000000080002c2e in kerneltrap () at kernel/trap.c:156
    #2  0x0000000080005b58 in kernelvec () at kernel/kernelvec.S:38
    Backtrace stopped: frame did not save the PC
    (gdb) p p
    $1 = <optimized out>
    (gdb) p cpus[0].proc->name                # the running process
    $2 = "schedcheck\000\000\000\000\000"
    (gdb) p cpus[0].proc->slice_used
    $3 = 1
    (gdb) p ticks
    $4 = 65
    (gdb) delete                              # remove all breakpoints
    (gdb) continue

    The interrupt came from kerneltrap(): the job was inside the system call cputicks(). The backtrace ends at kernelvec, where the trap entered the kernel; "Backtrace stopped" is normal there. The kernel is compiled with optimization, so gdb cannot show some local variables, such as p here. With one CPU, cpus[0].proc is always the running process (0 when no process runs).

    gdb: list the processes

    Save the lines below as procs.gdb in the repository folder. In gdb, type source procs.gdb once; after that, the command procs prints every process with its state and scheduling fields. Add other fields, such as priority or allot_used, in the same way.

    # procs: print every process in use, with the scheduling fields.
    define procs
      set $i = 0
      while $i < 64
        if proc[$i].state != UNUSED
          printf "%3d %-10s ", proc[$i].pid, proc[$i].name
          output proc[$i].state
          printf "  level=%d enter_tick=%d slice_used=%d wait_ticks=%d\n", proc[$i].level, proc[$i].enter_tick, proc[$i].slice_used, proc[$i].wait_ticks
        end
        set $i = $i + 1
      end
    end

    All the jobs of a test are named schedcheck (mlfqcheck in q4). Their pids are in the order A, B, C, …, so in the sessions below (the first test after boot) pid 4 is A, pid 5 is B and pid 6 is C. Use procs to find the pids in your own run.

    gdb: recipes

    A test hangs. Press Ctrl-c in gdb, then look at where the CPU is and at the processes. Here the kernel does not call sched_tick():

    ^C
    Program received signal SIGINT, Interrupt.
    scheduler () at kernel/proc.c:457
    457     intr_on();
    (gdb) p ticks
    $1 = 109
    (gdb) procs
      1 init       SLEEPING  level=0 enter_tick=1 slice_used=0 wait_ticks=0
      2 sh         SLEEPING  level=0 enter_tick=59 slice_used=0 wait_ticks=0
      3 schedcheck SLEEPING  level=0 enter_tick=59 slice_used=0 wait_ticks=0
      4 schedcheck SLEEPING  level=0 enter_tick=59 slice_used=0 wait_ticks=0
      5 schedcheck SLEEPING  level=0 enter_tick=59 slice_used=0 wait_ticks=0
      6 schedcheck SLEEPING  level=0 enter_tick=59 slice_used=0 wait_ticks=0
    (gdb) continue
    ^C
    (gdb) p ticks
    $2 = 139

    ticks grows, but every process is SLEEPING and the CPU waits in scheduler(). The jobs never leave schedstart(): nothing wakes them up at their start tick.

    The kernel panics. Set a breakpoint on panic before you type continue. When the panic happens, bt shows the code that caused it. Here should_preempt() still holds p->lock when it calls pick_rr(). pick_by() then tries to acquire the lock of every process, including p:

    (gdb) break panic
    (gdb) continue
    Breakpoint 1, panic (s=s@entry=0x80008048 "acquire") at kernel/printk.c:139
    (gdb) bt
    #0  panic (s=s@entry=0x80008048 "acquire") at kernel/printk.c:139
    #1  0x0000000080000c28 in acquire (lk=lk@entry=0x80014020 <proc+1248>) at kernel/spinlock.c:26
    #2  0x00000000800025d8 in pick_by (level=0, before=<optimized out>) at kernel/sched.c:99
    #3  pick_rr (level=level@entry=0) at kernel/sched.c:121
    #4  0x0000000080002782 in should_preempt (p=0x80014020 <proc+1248>) at kernel/sched.c:222
    #5  sched_timer () at kernel/sched.c:236
    #6  0x0000000080002b98 in usertrap () at kernel/trap.c:85
    #7  0x0000003ffffff09c in ?? ()
    

    Which code changes a field? watch -l stops whenever a memory location changes, and shows the old and the new value. Here it watches the time slice of the process that is running when you set the watchpoint. -l fixes the address, so it keeps watching that process after a switch:

    (gdb) watch -l cpus[0].proc->slice_used
    Hardware watchpoint 2: -location cpus[0].proc->slice_used
    (gdb) continue
    Hardware watchpoint 2: -location cpus[0].proc->slice_used
    
    Old value = 1
    New value = 2
    charge (p=0x80014030 <proc+1248>) at kernel/sched.c:181
    (gdb) bt
    #0  charge (p=0x80014030 <proc+1248>) at kernel/sched.c:181
    #1  sched_tick () at kernel/sched.c:194
    #2  0x0000000080002a1e in clockintr () at kernel/trap.c:172
    #3  0x0000000080002a92 in devintr () at kernel/trap.c:214
    #4  0x0000000080002bcc in kerneltrap () at kernel/trap.c:148
    #5  0x0000000080005b58 in kernelvec () at kernel/kernelvec.S:38

    Which process does the picker return? Stop in sched_pick() and type finish. gdb runs to the end of the function and prints the return value. $ is the last value that gdb printed, and $$ the one before it.

    (gdb) break sched_pick
    (gdb) continue
    Breakpoint 2, sched_pick () at kernel/sched.c:127
    (gdb) finish
    Run till exit from #0  sched_pick () at kernel/sched.c:127
    0x0000000080001e24 in scheduler () at kernel/proc.c:461
    461     if ((p = sched_pick()) != 0) {
    Value returned is $3 = (struct proc *) 0x80014030 <proc+1248>
    (gdb) p $->pid
    $4 = 4
    (gdb) p *$$                               # every field of that process
    $5 = {lock = {locked = 1, ...}, state = RUNNABLE, ..., pid = 4, level = 0, priority = 5,
      burst_est = 2, cur_burst = 2, slice_used = 2, allot_used = 0, wait_ticks = 2,
      enter_tick = 66, ...}

    Stop for one process only. Add a condition to a breakpoint. Here gdb stops only when job C (pid 6) wakes up:

    (gdb) break sched_wakeup if p->pid == 6
    (gdb) continue
    Breakpoint 1, sched_wakeup (p=p@entry=0x80014370 <proc+2080>) at kernel/sched.c:158
    (gdb) bt
    #0  sched_wakeup (p=p@entry=0x80014370 <proc+2080>) at kernel/sched.c:158
    #1  0x0000000080002746 in sched_tick () at kernel/sched.c:202
    #2  0x0000000080002a1e in clockintr () at kernel/trap.c:172
    ...

    gdb: commands for this lab

    CommandWhat it does
    continue (c)Run until the next breakpoint. Ctrl-c stops xv6 at once.
    break f (b f)Stop when function f starts. break f if cond stops only when cond is true. tbreak f stops once.
    info breakpoints, delete [n]List the breakpoints; remove one, or all.
    btShow the call chain. frame n moves to one frame of it; up and down move by one.
    next (n), step (s), finishRun one line; run one line and enter calls; run to the end of the function and print its return value.
    p exprPrint a value: p ticks, p proc[3], p *cpus[0].proc, p/x var (in hex).
    display exprPrint expr at every stop.
    watch -l exprStop when the memory of expr changes.
    listShow the source code around the current line.
    • A breakpoint in a function that runs at every tick, such as sched_tick(), stops at every tick. Add a condition, or remove the breakpoint when you are done with it.
    • To stop, type quit in gdb. xv6 then keeps running; press Ctrl-a and then x in the first pane to stop QEMU.
    • Run ./grade without gdb. It starts its own QEMU.

    Part 10Grading and submission

    This lab is worth 7 points of the course grade: 3 for the implementation and 4 for the lab quiz.

    Implementation (3 points)

    The five tasks add up to 100 points, scaled to 3. In each task, the public tests give 40% of the points and 10 hidden seeds give 60%.

    TaskBranchPointsPublic (40%)Private (60%)
    Task 1q010./grade (5 tests)10 hidden seeds
    Task 2q110./grade (5 tests)10 hidden seeds
    Task 3q210./grade (7 tests)10 hidden seeds
    Task 4q310./grade (4 tests)10 hidden seeds
    Task 5q460./grade (20 tests)10 hidden seeds
    • The TAs grade the last commit on each branch in your zip. Uncommitted changes are not graded.
    • The TAs use their own copies of ./grade and of the files you must not change (Part 0).
    • A build failure gives 0 for that task. A kernel panic or a hang gives 0 for that test or seed.
    • Each hidden seed may run for up to 60 s. A correct run takes about 15 s.
    • Passing ./grade does not guarantee the hidden seeds. They may include larger workloads, ties and blocking that the public tests do not cover. Write your own tests, try schedtest with other seeds (for example 7 and 42), and check the Gantt charts against the rules by hand.

    Lab quiz (4 points)

    The lab quiz covers the Trace Code section of q4-spec.md, and may also cover your implementation. Be ready to explain each item from the code.

    1. The two paths of a timer interrupt, and how stvec is switched between the two handlers:
      • prepare_return → uservec → usertrap → devintr → clockintr: the interrupt comes in user mode;
      • usertrap → kernelvec → kerneltrap → devintr → clockintr: the interrupt comes in a system call or in the scheduler loop.
    2. The hardware clock. The timebase-frequency of QEMU virt is 10000000. How long is one tick? When does the hardware raise a timer interrupt? Which line in clockintr() stops it from being pending? What happens when you comment out the line that writes stimecmp? Read clockintr() (its start is in the Task 1 code) and try the experiment.
    3. The six paths of the state diagram. Click a path to open it in the diagram:
      • New → Ready: userinit / kfork → allocproc
      • Running → Ready: usertrap / kerneltrap → sched_timer → sched → swtch (see also the two hooks)
      • Running → Waiting: sys_pause → sleep_prepare → sleep → sched (see also sleep and wakeup)
      • Waiting → Ready: clockintr → sched_tick → sched_wakeup (see also pause())
      • Running → Terminated: sys_exit → kexit → sched (see also exit)
      • Ready → Running: scheduler → sched_pick → swtch (see also the scheduler loop)
      For each path, explain which processes are in the ready queue, how their order is decided (the ready queue and your pickers), how the scheduler picks one (the scheduler loop), and how the context switch happens (Part 5).
    4. Your implementation. Be ready to explain your code, including your answers to the Think first questions in each task box.

    How to prepare for the quiz?

    • Understand each policy. For every task, be ready to explain which process runs next, when the running process gives up the CPU, and why. Dry-run your scheduler on a small workload: take a test from schedcheck (or mlfqcheck), write down the events you expect tick by tick, and compare them with the real output in the log viewer of Part 8.
    • Follow a timer interrupt all the way to sched(). Start from the hardware that raises the interrupt and go step by step until the CPU switches to the scheduler thread. At each step, know which function runs, what it does, and which locks are held.
    • Trace the code yourself. For every item in the Trace Code section of q4-spec.md, open the files and follow the calls. This page explains the paths, but the quiz asks about the code.
    • Compare the policies. Review the strengths and weaknesses of each scheduling algorithm from the lecture, then look for them in your own lab. schedtest builds the same workload from the same seed on every branch, so run schedtest 42 on q0 to q4 and compare the Gantt charts and the turnaround tables. Which jobs wait longest under each policy? How soon does a short job that arrives late finish? Which policy switches between jobs most often?

    Submit

    1. Commit your work on every branch. To check, run:
      for b in q0 q1 q2 q3 q4; do git log -1 --oneline $b; done
    2. Run make clean in the container to keep the zip small.
    3. Zip the whole os-xv6-lab-student folder, including the hidden .git folder (it holds all your branches), as <student_id>.zip. Run this in the folder that contains os-xv6-lab-student:
      zip -r <student_id>.zip os-xv6-lab-student          # macOS / Linux
      tar -a -c -f <student_id>.zip os-xv6-lab-student    # Windows (PowerShell)
    4. Upload <student_id>.zip to the eLearn assignment by 2026/11/10 23:59 (UTC+8).

    Do not push your work to a public repository, for example a fork of the lab repository: everyone could see your code.

    AttributionCredits

    • The scheduling lab (its specs, starting code, tests and grading) was created by the TAs 嘉宏 Alvin、又歆 Yuhsin、佳晉 Johnny.
    • The lab code is a modified version of xv6-riscv (MIT PDOS, MIT license). All logs and gdb sessions on this page are real output from the lab code in the course container.
    • The course illustrations of Nini and Niko are NTHU CS Operating Systems course material (Tony Chen / Yun-Chih Chen), licensed under CC BY 4.0.

    Cat Left

    Made with ❤️ by Tony, (CC BY 4.0)
    Cat source: Freepik and ChatGPT

    Cat Right