Project 3: Input, Output, and Sharing the Screen
At the end of Project 2, your operating system could hold several processes in memory and switch the CPU between them, whether they cooperated or not. But look at what your OS can actually do for you: it can print. It can show you text scrolling past on a serial UART connection or a printer.
In this project you will give your OS the ability to support more interactive computer use. We’ll approach this in two parts. First, you will extend your kernel to take a hardware interrupt when it receives data on the UART (e.g., because the user pressed a key), and hand that data to whichever process is waiting for it. This first step will get you to mid-1960s “time sharing”: a single, centralized and multiprogrammed computer can talk to many remote, dumb terminals that provide text-based input and output to the user’s processes on the computer. Second, you will add support for graphical output to the screen. Your kernel will ask the Raspberry Pi’s GPU for a framebuffer, a rectangle of memory that corresponds to what is on the screen, and let your processes draw pixels on it—and then, once several of them want to draw at once, give each process a private canvas of its own and combine them onto the screen itself. This gets us to where 1980s “personal computing” was, and to the genesis of the ancestors of popular modern OSes like Windows and macOS.
Putting these components together, you’ll get several programs on screen that seem to run at the same time. They are not actually running at the same time, of course. There is one CPU and one screen, and your kernel is time-slicing the CPU and partitioning the screen. This illusion of simultaneity is one that we take for granted on every computer we use.
Here’s an example of what the output to the screen might look like after you finish this project:

A note on what you already have. This project builds directly on Project 2: your exception vector table, your timer driver, your interrupt dispatch, your context switch, your scheduler, and the F_BASE trampoline that lets processes call into the kernel. It also leans on Project 1’s UART driver, which you are about to extend. If parts of earlier projects don’t work, fix them first—in particular, if your interrupt handling is shaky, everything in this project will be miserable. Come to office hours or contact the instructors if you need help getting your Project 2 code into shape.
Learning objectives
After completing this project, you will be able to:
- Explain how hardware interrupts let a kernel handle incoming data without waiting for it. Instead of spinning in a loop asking a device “do you have anything for me yet?”, your kernel will be interrupted by the device the instant data arrives, do the minimum work needed to not lose it, and go back to whatever it was doing.
- Explain that an input device is a shared resource, and how a kernel routes input to the right process. There is one keyboard and several processes. Something must decide who gets each keystroke, and you will implement that decision.
- Explain what it means for a process to “block”, and implement blocking. You will add a process state that is neither running nor ready-to-run but waiting for an event, and the sleep/wakeup mechanism that moves processes in and out of it. You will also discover why an OS with blocking needs an idle process.
- Explain what a framebuffer is and how a two-dimensional image is stored in one-dimensional memory. You will understand that a screen takes pixels, not text, and that everything you have ever seen on a screen—including every letter of this sentence—is software turning something else into pixels.
- Explain how an operating system partitions a shared output device between processes, and implement it: your kernel will give each process a private canvas to draw on, and decide where on the screen that canvas appears.
- Explain why drawing on a screen that is being displayed simultaneously goes wrong, and how a compositor fixes it. You will see “tearing”, and fix it with double-buffering and by adding a compositor that never shows output in the middle of being redrawn. (In CS 1690/2670 you will then make it fast enough for real hardware.)
- Explain why concurrent processes look simultaneous. You’ll see how one CPU, time-sliced a hundred times a second, and one screen, carved into four rectangles, can create the impression of an interactive desktop environment.
As a secondary objective, you will get more experience of writing a device driver against a vendor manual—this time for the PL011 UART’s receive path and the VideoCore GPU’s framebuffer.
CS 1670 vs. 1690/2670
As in Projects 1 and 2, all students share the same core project, but students in CS 1690/2670 complete extra work and run their code on real hardware.
Here is a summary of the quests you must complete for this project, based on which course you’re enrolled in:
| CS 1670 | CS 1690/2670 |
|---|---|
| Complete quests 1–4 in QEMU (emulated Raspberry Pi). | Complete quests 1–4 in QEMU (emulated Raspberry Pi). |
| Complete quest 5 (faster graphics on real hardware). | |
| Run your OS on real hardware (Raspberry Pi). |
Roadmap
The project has five quests, which build on each other. Quests 1–4 are required for everyone; quest 5 is for CS 1690/2670 only.
| Task | What you build | What you learn |
|---|---|---|
| 1. Hear the keyboard | Interrupt-driven receive in your UART driver, a route through the BCM2837 interrupt controller, and a ring buffer to receive input. | Why input must be interrupt-driven; the producer/consumer split between an interrupt handler and the rest of the kernel. |
| 2. Block, idle, and share the keyboard | Support for blocked (waiting) processes, logic to wake them up, a blocking getc, an idle process, and per-process input with a foreground process. | What “waiting” means to a kernel; the lost-wakeup race; why every real OS has an idle process; routing a shared input device. |
| 3. Pixels | A framebuffer driver built on the GPU mailbox, a drawing library and bitmap font, a text console, and a terminal application. | What a framebuffer is; that text is just pixels; that a “terminal” is an ordinary program; why drawing straight onto the screen tears. |
| 4. Windows | A private canvas for each process, and a compositor in the kernel that combines them onto the screen without ever tearing or showing a half-drawn frame. | Partitioning a shared output device; double-buffering; frame boundaries; why “simultaneous” desktop computing is an illusion. |
| 5. Faster graphics (CS 1690/2670) | A word-at-a-time memcpy, and damage detection: a compositor that copies only the windows that changed. | Why a correct design can be too slow on real hardware; doing unavoidable work more cheaply; not doing work you do not need to do. |
Read the Background section before you start: it gives you the conceptual foundation that all five quests build on. Each quest then opens with its own background material. Work through the quests in order; each one depends on the one before it.
Assignment Installation
You will keep working in the same private GitHub repository you used for Projects 1 and 2.
To pull in the new files for this project (new empty source files, the provided code, and an updated build system), run the following in your project directory:
git pull handout mainIf this reports an error about an unknown remote, run:
git remote add handout git@github.com:csci1670/cs1670-f26-projects.gitand then git pull handout main again.
Merge conflicts. As in Project 2, you have your own code in files that we also ship, so git pull may produce merge conflicts. Resolve them by keeping your implementation and adding whatever we provide alongside it. The likely places are:
kernel/memlayout.h. We have added comments about this project’s new memory regions at the end of the file. Keep yourF_BASE, your existingF_...slots, and your process memory constants.kernel/init.c. We have addedexterndeclarations for the new user programs, in the middle of the existing list (which is where they are least likely to collide with your code). Keep them and yourinit()function.
If you get stuck in a merge, git merge --abort gets you back to where you started; then come to office hours.
⚠️ Important extra step before you start: At the start of project 2, we fixed a bug in the ELF loader we had provided to you during project 1, which will cause problems loading some user programs. All of the programs we ran during project 2 were not affected by the bug, but you will encounter problems in project 3 if you don’t have our fix. Therefore, you should check your code now to make sure you have our updated loader. To do this:
- Open your
kernel/elf.cfile - Look for a function called
apply_relocations. If you don’t see it, re-copyelf.candelf.hfrom our ELF loader repo and replace your versions. If you modified the loader code, take care to preserve any modifications you made.- If you’d prefer to see the exact changes we made to these files and apply them yourselves, look at this commit.
Details about the bug
The loader we had initially released was missing support for relocations, which are special directives included in an ELF file that deal with code that uses global variables and function pointers. Machine code will refer to global variables using fixed addresses; however, this is a problem because the kernel decides where to copy the code in memory when it boots up, so the addresses written into the machine code by the compiler will be incorrect, since it doesn’t know where the code will be loaded!
Relocations are the way to solve this problem: ELF files contain a list of the points in the code that contains a pointers that would otherwise be incorrect. When the ELF file is loaded, the loader modifies each address by adding the actual address where the code will be placed in memory, so it contains the correct value.
If you don’t update your ELF loader and don’t use relocations, you’ll find that your code mostly works, except all references to global variables will be wrong, because they’ll be reading from incorrect addresses!
Code Structure
As in Projects 1 and 2, you write all the significant code yourself; we provide a build system, a skeleton of (mostly empty) files, and a few pieces that are pure plumbing. Here is what is new or changes in this project:
.
├── Makefile
├── kernel/
│ ├── ringbuf.c, ringbuf.h Quest 1: a byte ring buffer
│ ├── drivers/
│ │ ├── uart.c, uart.h Quest 1: add interrupt-driven receive
│ │ ├── mbox.c, mbox.h Quest 3: GPU mailbox protocol (we provide this)
│ │ ├── fb.c, fb.h Quests 3-4: the framebuffer driver
│ │ └── timer.c, timer.h (yours, from Project 2; Quests 3--4 change it)
│ ├── console.c, console.h Quests 1-2, 5: where keystrokes go
│ ├── compositor.c, .h Quests 4-5: combine every process's canvas onto the screen
│ ├── interrupts.c, .h Quests 1, 4: dispatch the UART interrupt; drive the compositor
│ ├── scheduling.c, .h Quest 2: BLOCKED, block(), wakeup()
│ ├── proc.h Quests 2, 4-5: BLOCKED state; input buffer; window and canvas
│ ├── proc.c Quests 2, 4: initialize the new process fields
│ ├── klib.c, klib.h Quests 2-4: a suggested home for the kernel-side
│ │ trampoline functions. You have been installing these
│ │ in kernel_main; moving them is optional, and we
│ │ give you the (empty) files.
│ ├── init.c Quests 2-4: load the idle process; lay out the windows
│ ├── kernel.c Quests 1-4: kernel_main initializes the new subsystems
│ ├── memlayout.h Quests 2-4: new F_... slots, FB_INFO_BASE, bigger processes
│ ├── utils.c, utils.h Quest 5: a faster memcpy
│ └── ...
└── user/
├── font8x8.h Quest 3: an 8x8 bitmap font (we provide this)
├── lib/ code compiled once and linked into every program
│ ├── u_common.c, .h Quests 2-4: user-side wrappers for the new kernel calls
│ ├── u_fb.c, u_fb.h Quests 3-5: your drawing library
│ ├── u_string.c, .h Quests 3, 5: strlen/strcmp/sprintf and friends
│ └── u_vterm.c, .h Quests 3, 5: a text console (your terminal's drawing half)
└── ... you will also create idle.c (Quest 2), keytest.c
(Quest 2), logo.c and term.c (Quest 3) yourselfYou will be adding a lot of files this project, and the build system will find them on its own. The Makefile finds source files automatically, so anything you drop into kernel/, kernel/drivers/ or user/lib/ gets compiled correctly. The same goes for user/u_klib.h, which user/gen_klib_header.py regenerates from every #define F_... line in kernel/memlayout.h. Do not edit u_klib.h by hand.
Some conventions about user/ are important. Every .c file directly under user/ is compiled into its own executable, while everything under user/lib/ is compiled once and linked into all of them.
We are not giving you empty idle.c, keytest.c, logo.c and term.c files, which you’ll need the create. An empty program makes the linker complain, so we’ll tell you to create each one when you get to it. kernel/init.c already has an extern declaration for all seven.
Building and running
We have added the commands make quemu-fb and make qemu-ws into the build system.
The targets that turn the display on now also need to connect your terminal to the emulated machine’s serial port, so that you can type into your OS. (Until now nothing read input, so it did not matter.)
$ make qemu # serial console only, in your terminal (as in Projects 1 and 2)
$ make qemu-fb # ...plus a graphical display window (native macOS)
$ make qemu-ws # ...plus a graphical display in your browser (container, or macOS)
$ make qemu-gdb # serial only, paused, waiting for gdb
$ make qemu-fb-gdb # window display + paused, waiting for gdb
$ make qemu-ws-gdb # browser display + paused, waiting for gdb
$ make cleanYou met make qemu-fb and make qemu-ws back in Project 0, where you used them to run our demo OS and change its background color. Go back and re-read those instructions now if you don’t remember which one is right for your environment—you will be using it constantly for the second half of this project.
Remember that to quit QEMU you type Ctrl-a then x, and that make kill gets rid of stray QEMU instances.
Where is your “keyboard”, exactly? QEMU’s Raspberry Pi emulation does not emulate a USB keyboard, and neither the display window nor the browser viewer sends keystrokes to your OS. Your keyboard is the serial (UART) console: the characters you type into the terminal where you run make qemu-fb are the bytes that arrive at your PL011 UART’s receive FIFO. If you are running on real hardware, they are the bytes your laptop sends down the TTL-to-USB cable.
(If you use the browser method, the serial console moves too: with CONSOLE=ws the web viewer’s terminal is where you type, not the shell you started make qemu-ws from.)
A real keyboard on a Raspberry Pi is a USB device, and a USB host controller driver is an enormous amount of code (upwards of 10k lines), so we don’t have you write one. The serial console setup is also historically accurate: on a 1970s minicomputer, your “keyboard” really was a serial terminal at the end of a wire.
Debugging gets harder again in this project. You’ll have an intput device and a second output device that can be wrong in its own ways. Appendix B collects the techniques that help. The CHECK() and DEBUG() macros from Project 2’s Appendix D remain your best friends. A CHECK() on “this pixel address is inside the framebuffer” can save you a lot of time on the framebuffer quests.
Background
Why polling is not good enough
Your UART driver from Project 1 sends a character like this:
void uart_send(char c) {
while (/* the transmit FIFO is full */) {
// spin
}
/* write the byte */
}That is polling: repeatedly asking the device whether it is ready. In Project 2’s background section we worked out what this costs: about 120,000 wasted instructions per character at 115,200 baud.
A change of plan since Project 2. Project 2 told you that you would fix the send busy-wait here. We have made that extra credit instead (Quest 1-E1) and spent the quest on the receive side, because receiving is more interesting. Receiving input requires support for buffering, blocking, and deciding who the data belongs to, which all require significant new kernel features.
For output, polling is merely wasteful. For input, it is much worse than that:
You do not know when input will arrive. A human being presses a key whenever they feel like it. There is no bound on how long you might wait—seconds, minutes, or even hours. A polling read means a process (and, since there is no preemption inside your kernel’s busy-wait, possibly the entire machine) sits in a loop doing nothing for an unbounded amount of time, while other processes that have real work to do are ready and waiting.
And if you don’t poll often enough, you lose data. The PL011’s receive FIFO holds 16 bytes. If you paste a line into your terminal (equivalent to typing very fast), the characters that arrive can be silently overwritten (and lost forever).
The answer is the mechanism you already built in Project 2: interrupts.
Instead of the CPU polling the device, the device interrupts the CPU. When a byte arrives, the UART raises an IRQ. The CPU stops whatever process is running, jumps into your kernel, and your kernel takes the byte out of the FIFO and puts it somewhere safe. Then the interrupted process resumes, never having known that anything happened.
This is the general shape of all device input in an operating system.
An interrupt handler must be short
An interrupt handler runs at an arbitrary moment with interrupts masked. Everything it does is time during which the machine cannot respond to anything else. So a handler should do the minimum required to not lose data, and get out. It should not print a long message, it should not do a slow computation, and it certainly should not wait for anything.
For the keyboard, the minimum amount of work is to take the bytes out of the hardware FIFO and put them into a buffer in memory. Deciding what the bytes mean, who they belong to, and what to do about them is not the handler’s job; it is the job of kernel code running later, on behalf of a process. In Linux, this is called deferred work.
In this project, we will use a ring buffer (also called a circular buffer), to allow the handler to defer work. A circular buffer is a fixed-size array with a read position and a write position that can wrap around. The interrupt handler pushes to the buffer, and a process pops from it. One of the strengths of a circular buffer is that it needs no memory allocation and no locks when there is one producer and one consumer.
Blocking: A new process state
Your Project 2 process descriptor has four states: UNUSED, USED, RUNNABLE, and RUNNING. Notice that it has no way to express a common situation in a real operating system:
This process cannot make progress right now, and there is no point in ever giving it the CPU until something specific happens.
That situation is called being “blocked”. Right now, if you wrote getc() as a loop that yields until a key is available, the process would be RUNNABLE the whole time. Your round-robin scheduler would dutifully put it on the CPU, over and over, so that it could discover—over and over—that there is still no input. As the number of waiting processes increases, this can take up most of the CPU’s cycles.
Blocking fixes this by making the scheduler skip blocked processes entirely. Later, when the event a process was waiting for happens, whoever caused the event wakes it up by moving it back to RUNNABLE state, so the scheduler will consider it again.
Unix conventionally calls the two halves sleep and wakeup. They’re connected by a “wait channel”: an arbitrary value, almost always the address of some kernel data structure, that identifies “the event associated with this wait.” A process blocks on the keyboard buffer’s address; the UART interrupt handler wakes up everyone blocked on that same address.
Where else does blocking show up?
Essentially everywhere, once you look. On a modern OS, reading from a file blocks until the disk delivers the data; accept() on a socket blocks until a connection arrives; wait() blocks until a child process exits; acquiring a contended mutex blocks until the holder releases it. In every case the pattern is the same as the one you are building in this project: a process marks itself blocked on a channel, gives up the CPU, and relies on another process or the kernel else to wake it up again.
What is on a screen?
A framebuffer is a region of memory that the display hardware reads, continuously, and turns into pixels on the screen.
You already met the pixel format in Project 0, when you set the demo OS’s background color. Each pixel is a 32-bit number holding one byte each of blue, green, and red (plus an unused byte):
0xAABBGGRR
AA - unused ("alpha") (1 byte)
BB - blue channel (1 byte)
GG - green channel (1 byte)
RR - red channel (1 byte)So 0xFF0000FF is red, 0xFF00FF00 is green, 0xFFFFFFFF is white, and 0xFF000000 is black.
Project 0 wrote the same format as 0x00BBGGRR and left the top byte zero, which is why your favorite color from that assignment looked like 0x00rrggbb reversed. At 32 bits per pixel the display ignores that byte either way; we will write 0xFF into it and call it alpha.
The screen is two-dimensional and memory is one-dimensional, so the framebuffer stores the image one row at a time, top to bottom, left to right. To find the pixel at column x and row y, you skip y whole rows and then x pixels:
pixels[y * pitch_in_pixels + x] = color;pitch is the number of bytes from the start of one row to the start of the next. You might expect it to be exactly width * 4, but the hardware is allowed to round it up for alignment, so a 640-pixel-wide display might have a pitch of 2560 bytes (exactly 640 * 4) or something larger. You must ask the hardware what the pitch is and use that, not compute it from the width. Getting this wrong produces a famous and very recognizable symptom: an image that appears sheared diagonally across the screen.
Everything is pixels. Really everything.
From the hardware’s perspective, there is no such thing as printing text to a screen. When you write a letter ‘A’ to a modern terminal, an enormous stack of software—the terminal emulator, a font rasterizer, a compositor, a graphics driver—turns that byte into a specific set of pixels. In Quest 3 you will implement the bottom of that stack yourself.
The screen is a shared resource, too
Your kernel already mediates access to the CPU: exactly one process runs at a time, and your scheduler decides which. From this project onward, it must mediate a second resource, and the screen behaves quite differently from the CPU.
The CPU is shared in time: everybody gets all of it, some of the time. The screen is shared in space: everybody gets some of it, all of the time. In Quest 4, your kernel will give each process a private canvas—a block of memory the size of its window—and copy every canvas onto its own part of the screen. A process draws only on its canvas; where that canvas appears is the kernel’s decision, not the process’s.
“Only on its canvas” is a promise, not a guarantee. Your OS has no virtual memory and no memory protection. A process is given a pointer to its own canvas and nothing else, so it cannot scribble on another window by accident—but nothing stops it computing any address it likes and writing there, including another process’s canvas, or the screen itself. Nothing stops it and nothing reports it.
In Project 5 you will build the virtual memory machinery to prevent this from happening.
The keyboard, meanwhile, is a shared input resource, and it cannot be divided in either space or time: there is one stream of keystrokes and it has to go somewhere. In most windowing systems, one window at a time has the focus (or set it as the foreground process), and that is who the keys go to. You will implement that at the end of Quest 2. –>
Quest 1: Hear the keyboard
Files: kernel/drivers/uart.c, kernel/drivers/uart.h, kernel/drivers/timer.c, kernel/interrupts.c, kernel/interrupts.h, kernel/ringbuf.c, kernel/ringbuf.h, kernel/console.c, kernel/console.h, kernel/kernel.c.
Overview
In this quest you will make the UART device interrupt your kernel when a character arrives. You will:
- Find the PL011’s interrupt-related registers in the manual, and the registers of a second interrupt controller you have not used before.
- Enable the UART’s receive interrupt, route it to your CPU core, and extend your Project 2 interrupt dispatch to recognize it. As a first step, your handler will just print the character it received.
- Replace the print with a ring buffer that the interrupt handler fills and that the rest of the kernel drains.
At the end of the quest, typing on your keyboard will produce characters inside your operating system.
Why are we doing this?
A network card, a disk controller, a mouse, and a keyboard all share this same exact pattern. The device raises an interrupt, a very short handler moves data out of the hardware into memory, and ordinary code deals with it later.
It is also the first time you will handle an interrupt that is not the timer. In Project 2 your dispatch code had exactly one case, so it did not really have to dispatch. Now it does, and your kernel will have to answer the question of which device just triggered an interrupt.
Background: the PL011’s receive path
You already know most of the PL011 UART from Project 1. Receiving is the mirror image of sending:
- Incoming bytes land in a 16-entry receive FIFO inside the device.
- The flag register
FRhas a bit (RXFE, “receive FIFO empty”) that is 1 when there is nothing to read. It is the counterpart of theTXFFbit you poll when sending. - Reading the data register
DRremoves one byte from the FIFO and gives it to you. (The low 8 bits are the character; the upper bits are error flags for framing and parity errors, which we will ignore.)
What is new is the interrupt machinery. A PL011 can raise an interrupt for a dozen different conditions, and it does not raise any of them until you configure it to do so. Three registers control this:
| Register | Role |
|---|---|
IMSC | Interrupt Mask Set/Clear. One bit per interrupt source. Setting a bit unmasks (enables) that interrupt. |
MIS | Masked Interrupt Status. Read-only: which interrupts are currently asserted and unmasked. Tells you why the device interrupted you. |
ICR | Interrupt Clear. Write a 1 to a bit to acknowledge that interrupt. |
You want to raise an interrupt in response to only one of the interrupt sources: the receive interrupt, RXI, which fires when the receive FIFO reaches its trigger level.
You must acknowledge the interrupt. If your handler reads the character but never writes to ICR, the UART keeps asserting its interrupt line. The instant your handler returns and re-enables interrupts, you take the same interrupt again, forever. Your machine will appear to hang while in fact doing nothing but taking interrupts.
This failure mode is called an interrupt storm, and forgetting to acknowledge an interrupt is a common bug in device drivers.
Background: a second interrupt controller
In Project 2 you enabled the timer interrupt by writing to a register called “Core 0 timers interrupt control” at 0x4000'0040, and you read “Core 0 interrupt source” at 0x4000'0060 to find out what had happened. Those registers belong to the ARM local peripherals—the logic that sits right next to the ARM cores—and they are documented in the QA7 document, not the BCM2835 peripheral manual.
The UART is not an ARM local peripheral. It is a peripheral hanging off the Broadcom SoC, alongside the GPIO, the system timer, the SD card controller, and everything else. Those devices share a different interrupt controller, documented in §7 of the BCM2835 peripheral manual you used in Project 1, based at PERIPHERALS_BASE + 0xB000.
So the path a keystroke takes to reach your code has two hops:
key pressed on your keyboard
|
v
[ PL011 UART ] raises RXI, if unmasked in its IMSC register
|
v
[ BCM2835 interrupt controller ] forwards it, if enabled in ENABLE_IRQS_2
|
v
[ ARM local interrupt controller ] reports "a peripheral IRQ is pending"
| in bit 8 of Core 0 interrupt source
v
[ your CPU core ] takes an IRQ exception -> your vector table -> handle_interrupt()Your handle_interrupt therefore has to check two things: first with the ARM local controller (“what kind of interrupt?”), and then, if the answer is “a peripheral”, with the BCM2835 controller ("which peripheral?").
The BCM2835 controller has 64 peripheral interrupt sources, spread across two banks of 32, plus 8 “basic” ones. Each bank has an enable register and a pending register:
| Register | Offset from PERIPHERALS_BASE | Role |
|---|---|---|
IRQ_PENDING_1 | 0xB204 | Which of IRQs 0–31 are pending |
IRQ_PENDING_2 | 0xB208 | Which of IRQs 32–63 are pending |
ENABLE_IRQS_1 | 0xB210 | Write a 1 bit to enable one of IRQs 0–31 |
ENABLE_IRQS_2 | 0xB214 | Write a 1 bit to enable one of IRQs 32–63 |
The PL011 UART is IRQ 57, which lives in bank 2 at bit 57 − 32 = 25.
What you need to do
Part A: find the registers
Task: Using the BCM2835 ARM Peripherals manual (see Appendix A), determine and record:
- In
kernel/drivers/uart.h, the offsets of the PL011’sIMSC,MIS,ICRandIFLSregisters, the bit position of the receive interrupt (RXI) within the first three, and the bit position of the receive timeout interrupt (RTI) alongside it. The UART is documented in §13 of the manual. You will want the register summary and then the individual register descriptions. (IFLSandRTIare for the hardware wrinkle described below—you need them only if you are running on a real Pi, but find them now while you have the manual open if you are in CS 1690/2670.) - In
kernel/interrupts.h, the addresses ofIRQ_PENDING_2andENABLE_IRQS_2as offsets fromPERIPHERALS_BASE, and the bit for the UART’s IRQ. The interrupt controller is documented in §7.
As in Project 1, express every address as PERIPHERALS_BASE + offset, and give each constant a name that matches the manual’s.
🤖 AI use/coding: auxiliary use allowed, including for reading the manual. You may ask AI to help you locate and interpret sections of the BCM2835 datasheet, and to explain what a register does. You must then check every number you get against the manual yourself and write down where you found it.
Why?
In Project 1 we made you read the datasheet entirely unaided, because that experience is usefult to go through. But using an AI as a search-and-summarize layer over a 205-page manual is a completely reasonable thing for a working engineer to do. However, it is also a thing AI is unusually bad at: hardware manuals are dense tables of near-identical names, and models confidently produce plausible, wrong offsets, or answer about a different chip entirely (the Raspberry Pi has several UARTs and several interrupt controllers). The skill we want you to practise here is not “avoid the tool”, it is “use the tool and then verify”.
For context, see the principles around AI use in CS 1670.
Hints
- Be specific about which UART when you ask anything: the device is a PL011. The Raspberry Pi also has a “mini UART” (the
AUXdevice) with a completely different register layout. - The PL011 is an ARM design used in many chips, so the ARM PrimeCell UART (PL011) Technical Reference Manual describes the same registers in more detail and with fewer mistakes than the Broadcom manual.
- You do not strictly need
MISfor this project—if you unmask only the receive interrupts, you know why the UART interrupted you. We recommend that you define it anyway. It is what you would use in a driver that handles more than one condition.
Part B: take the interrupt
Now wire it up.
- In the device: unmask
RXIin the UART’sIMSCregister, at the end ofuart_init(), after the UART itself is enabled. - In the interrupt controller: set the UART’s bit in
ENABLE_IRQS_2. The natural home for this is yourenable_interrupt_controller()function from Project 2, next to the line that enables the timer. - In your kernel: extend
handle_interrupt()to recognize the new source, and write auart_interrupt()function in your driver to handle it.
Your uart_interrupt() should drain the whole receive FIFO, not just one byte. Loop while RXFE is clear, reading a character each time. Then acknowledge the interrupt by writing to ICR.
Why drain the whole FIFO?
The receive interrupt fires when the FIFO reaches a trigger level, not when it contains exactly one byte—and more bytes can arrive while your handler is running. If you read exactly one byte per interrupt, you can fall behind. When the FIFO fills, the oldest bytes are overwritten, and characters are lost. Draining in a loop means the handler always leaves the device empty, which is the only state from which you can be sure the next byte will produce a fresh interrupt.
This is the general rule for FIFO-based devices: handle everything that is pending, then acknowledge. Acknowledging first and then reading opens a window in which a byte can arrive and be counted in an interrupt you just cleared. That byte then sits in the FIFO with nothing scheduled to come collect it.
Important CS 1690/2670 note. The PL011’s interrupt FIFO level register (IFLS) by default is set to half full—so with the FIFOs enabled, the device will not raise an RXI interrupt until eight characters are waiting. Nobody types eight characters at once, so single keystrokes would produce no interrupt at all!
QEMU’s PL011 model interrupts on every byte regardless, so your code will appear to work perfectly in emulation and then do nothing on the Pi. There are two standard fixes you could make:
- Unmask the receive-timeout interrupt (
RTIM) as well asRXIM. The receive timeout fires when the FIFO is non-empty and nothing new has arrived for a short while, which is exactly the “the human stopped typing” case. Remember to acknowledge it inICRtoo. This is what a production driver does. - Lower the trigger level by writing
IFLS, e.g., to 1/8 full.
If you are in CS 1690/2670, you should make sure to test on real hardware.
Task: Make your OS take an interrupt when you press a key, and prove it by printing.
- Unmask
RXIinterrupts in the appropriate place. - Enable the UART’s IRQ in your
enable_interrupt_controller. - Extend your interrupt handler (
handle_interruptor similar). Read the ARM local “Core 0 interrupt source” register as you already do; if the timer bit is set, call your timer handler as before. If bit 8 (“a peripheral IRQ is pending”) is set, readIRQ_PENDING_2, and call a new UART interrupt handler function that you create (see below) if the UART’s bit is set there.panic()on anything else, as you did in Project 2. - Give
handle_interrupt()a return path—see the danger box below. - Write a UART interrupt handler (e.g.,
uart_interrupt()) in your UART driver. This should drain the receive FIFO,printfeach character, and acknowledge the interrupt viaICR. - Stop your timer handler from re-enabling interrupts, if it does—see the second red box below.
Then run make qemu and type. You should see each keystroke reported.
Gotcha #1: Your handle_interrupt didn't return before, but now it might.
In Project 2, the interrupt handler had just one useful branch: the timer, which ends in your scheduler and never comes back. That is why you were told to mark it [[noreturn]], and why its last line is unreachable.
The UART is different. Nothing about a keystroke arriving means the running process should be descheduled—it did nothing wrong and should carry straight on. So uart_interrupt() returns, and when it does, some code must resume the interrupted process itself:
restore_context(context); // the context you were passed; never returns
You could do this inside the interrupt handler (and keep the [[noreturn]]), or you could do after the handler returns.
If you forget to add this code, control falls off the end of handle_interrupt and back into the IRQ entry stub in exceptions.S, whose next instruction is the b . you put there in Project 2. This creates a bug whose symptom is a machine that works perfectly until the first keypress and then hangs forever.
Gotcha #2: Your timer handler must stop re-enabling interrupts.
Many correct Project 2 implementations call enable_interrupts() in the timer path, just before invoking the timer callback that runs the scheduler. With a single interrupt source, that was harmless. Now there are two: if a keystroke arrives while your kernel is halfway through switching processes, the interrupt forces the kernel to enter handle_interrupt (or your equivalent function), which overwrites current_process->context with a context pointing into the middle of your scheduler.
The fix is to delete the call to enable_interrupts if you have it. Your kernel runs with IRQs masked, and the eret back into a process unmasks them again from the process’s saved SPSR when returning to user code. The symptom if you leave the early interrupt enable in is a crash or a hang that happens only sometimes, and only while you are typing—one of the hardest kinds of bug to find.
Note we are deliberately breaking our own rule here. A printf inside an interrupt handler is exactly the kind of slow work that we said above an interrupt handler should not do. Your printf polls the transmit FIFO, so handling one keystroke takes tens of thousands of cycles with interrupts masked. This is fine as a stepping stone and for debugging here, and you will remove it in Part C. But notice how easy it was to write the wrong thing!
Hints
- Build this in the order given, and test after step 3 by not yet writing
uart_interrupt—instead, temporarilypanic("UART interrupt!"). If pressing a key panics your kernel, the entire routing path works, so you can build a proper handler. - If nothing happens at all when you type: check, in order, (a) that you actually wrote
IMSC(and after enabling the UART inCR), (b) that you wrote the right bit inENABLE_IRQS_2, (c) that you are testing bit 8 and not bit 1 of the core interrupt source register. - If your kernel hangs or floods the console the moment you press a key, you have an interrupt storm: you are not acknowledging. Check
ICR. - If you get a
panic("Unknown pending interrupt"), print the value you read from the core interrupt source and fromIRQ_PENDING_2in the panic message. Whatever bit is set will tell you what you got wrong. - Remember that
mmio_read32/mmio_write32fromkernel/utils.hare how you touch all of these registers.
🤖 AI use/coding: auxiliary use allowed. You may use AI to explain the PL011 interrupt registers, to help you debug why an interrupt is or isn’t arriving, and to check your bit arithmetic. You may not hand it the task of writing your interrupt dispatch.
Why?
We want you to understand how device interrupts are delivered and handled. The surrounding work—decoding a register description, or figuring out which of your three changes is broken—is fine to leverage AI for.
For context, see the principles around AI use in CS 1670.
Part C: a ring buffer
Printing in the handler works, but it is the wrong design in two ways: it does slow work at interrupt time, and it throws the data away. What you actually want is to move the byte somewhere safe as fast as possible, and let ordinary kernel code deal with it later.
The “somewhere safe” is a ring buffer: a fixed-size array plus two pieces of bookkeeping that say where the unread data starts and how much of it there is. Writing wraps around to the beginning when it reaches the end. You may recall ring buffers from CS 300/1310’s “bounded buffer” example in the concurrency part of the course. This is where the same idea of a bounded buffer shows up again!
Ring buffers are a very common kernel data structure, but they’re finicky to implement and get right. Therefore, we allow you to generate the code with AI (see below).
Task: Implement a byte ring buffer and use it to capture keystrokes.
- Define a
ringbuf_ttype and the operations you need: at minimum initialize, push, pop, and a way to ask whether the buffer is empty. Make the capacity a named constant (512 bytes are sufficient). If the buffer is full and a caller is trying to add more data, drop and count the byte, rather than overwriting. We recommend putting this code intokernel/ringbuf.{c,h}. - In
kernel/console.c, create a single global input buffer and a function that the UART driver calls for each byte it receives. For the rest of the handout, we will call this input bufferinput_bufand this functionconsole_handle_input. For now, the function can just push the byte into the buffer. - Add a
console_init()that initializes the buffer, and call it fromkernel_main. - Change
uart_interrupt()to callconsole_handle_input()for each character instead of printing it. - To convince yourself that the data really is arriving, you can add a temporary drain to your timer interrupt handler: pop every byte currently in the buffer and
printfit. You will delete this in Quest 2.
Hints
- Why put the buffer in
console.crather than in the UART driver? Because “where keystrokes go” is a policy decision that belongs to the OS, not a property of the device. Yourconsole.cwill grow into a full input data management layer over the next three quests. - The temporary drain in step 4 is a nice demonstration: keystrokes now appear in batches at the timer tick rather than instantly, because the capture and the use of the data have come apart in time!
- A ring buffer is small enough to test directly. In
kernel_main, write a fewCHECK()s to test the following behavior: push three bytes and pop them in order; fill it completely and check that the next push is dropped; wrap around the end of the array and check that nothing is corrupted. It is easy to get off-by-one errors, and they are much easier to find with testing than by typing at a running kernel.
🤖 AI use/coding: allowed, including agentic coding, for the ring buffer itself (ringbuf.c/ringbuf.h) and its tests. Auxiliary use allowed for the changes to console.c and the driver.
Why?
A ring buffer is a standard data structure with a standard implementation, and writing one is a good exercise but time-consuming. We would rather you spent your time on the parts of this project that help you learn more OS concepts. That said, you own this code and will use it for the rest of the course, so read and understand what you get from AI: check the wrap-around arithmetic, and make sure a full buffer drops rather than overwrites.
For context, see the principles around AI use in CS 1670.
Check your work
Run make qemu and type some characters. With the temporary drain from Part C step 4 in place, you should see something like:
Hello world from MyOS!
...
console: got 'h'
console: got 'e'
console: got 'l'
console: got 'l'
console: got 'o'We recommend that you check these three things:
- Type a burst. Paste a long line, or hold a key down. Every character should come through in order. If characters go missing, either you are not draining the FIFO in a loop, or your ring buffer’s wrap-around is wrong, or you are overflowing 512 bytes (which your dropped counter will tell you).
- Type nothing for a while. Your other processes should keep running exactly as they did at the end of Project 2. If the machine has become sluggish or stopped, you are probably taking interrupts you aren’t acknowledging.
- Look at it in
gdb. Set a breakpoint onuart_interrupt, press a key, and inspect:p/x $sp(you are on some process’s stack),p input_buf, and your ring buffer’s indices. Confirm that the process that got interrupted is an arbitrary one—press keys a few times and watch your current-process pointer change.
Bonus Quests (Extra Credit)
Important note: All extra credit work requires you to meet with an instructor at office hours to discuss your work in order to get credit. There will be no exceptions to this, since extra credit is strictly optional.
Quest 1-E1: Interrupt-driven transmit (moderate difficulty)
You have made receiving interrupt-driven, but uart_send still polls, burning roughly 120,000 instructions per character. The transmit side can be interrupt-driven too, and the design is the mirror image: a second ring buffer that printf pushes into, a transmit interrupt (TXI) that fires when the transmit FIFO has room, and a handler that moves bytes from your buffer into the device.
Task (extra credit): Make your UART driver’s transmit path interrupt-driven.
The interesting parts are the edge cases, and you should think about all of them: what does uart_send do when the output buffer is full (and why can’t it simply block, if it might be called from an interrupt handler)? Who starts the very first transmission, given that the transmit interrupt only fires when the FIFO drains and a completely idle device never drains? What must panic() do differently, given that it must print its message even though the machine is about to stop running?
Measure the difference: time how long a 1000-line output loop takes before and after, using CNTPCT_EL0.
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 2: Blocking, idling, and sharing the keyboard
Files: kernel/proc.h, kernel/proc.c, kernel/scheduling.c, kernel/scheduling.h, kernel/console.c, kernel/console.h, kernel/klib.c, kernel/kernel.c, kernel/limits.h, kernel/memlayout.h, kernel/init.c, user/lib/u_common.c, user/lib/u_common.h, user/idle.c, user/keytest.c, user/basic.c (provided).
Overview
Your kernel now collects keystrokes. In this quest you will give processes a way to read them, and—much more importantly—a way to wait for them without wasting the CPU. You will:
- Expose a
getc()function to user programs through the same trampoline mechanism you used forprintfandyield, in a first version that spins. - Add a
BLOCKEDprocess state and ablock()/wakeup()pair, and rewritegetc()to use them. Along the way you will meet a race condition that has cost real kernel developers a great deal of sleep. - Discover that your scheduler now panics, and fix it by adding an idle process.
- Give every process its own input, and let the user choose which process the keyboard is talking to.
At the end of the quest you will be able to run a BASIC interpreter on your operating system, type programs into it, and switch between it and another program with a keystroke.
Why are we doing this?
Preemption, which you built in Project 2, lets a process be taken off the CPU against its will. Blocking lets a process step aside voluntarily and indefinitely when they have nothing to do until an event occurs. This allows a machine to have many processes, most of which are currently waiting for something.
It is also the last piece of the process state machine that you will need for a long time: RUNNING, RUNNABLE, and BLOCKED are the three main process states in an OS kernel.
Background: how a process reaches the kernel, and blocks inside it
Recall the mechanism from Projects 1 and 2: your kernel writes function pointers into fixed addresses below F_BASE, and user programs call through them. printf works this way, and so does yield. getc will be the third.
Because your processes run at EL1—the same privilege level as your kernel—a call into the kernel is an ordinary function call. It uses the process’s own stack. There is no privilege transition, no separate kernel stack, no trap. When a process calls getc(), what is running is kernel code, on that process’s stack, with that process’s registers.
A note on interrupt discipline. Project 2 gave you a rule—processes run with IRQs enabled; the kernel runs with IRQs disabled—and a list of every way into and out of the kernel that supposedly made it true: an interrupt (hardware masks), yield_entry (masks by hand), and eret on the way back out.
The trampoline is a fourth path: it masks nothing. Kernel code reached through F_VPRINTF or F_GETC runs with the caller’s interrupt state, which is to say unmasked. That is why Project 2 had you wrap printf’s output in disable_interrupts/restore_interrupts rather than relying on the rule. You will have to similarly mask by hand in console_getc in a moment, or you could consider adding wrappers to mask/unmask interrupts for all user code library functions that enter the kernel.
This means a process can block in the middle of a kernel function, and resuming it is no different from resuming it anywhere else:
process calls getc() ──► kernel code runs on the process's stack
│
no input available
│
▼
mark the process BLOCKED, save its context,
run the scheduler ──► some other process runs
⋮
a key is pressed; the UART
interrupt handler wakes the
process
⋮
restore_context() ──► execution resume *inside getc()*,
loop around, find the byte,
and return it to the callerFrom the process’s point of view, getc() was a function call that took a long time. From your kernel’s point of view, a context was saved and later restored.
Background: wait channels, and the lost wakeup
The sleep/wakeup interface we recommend is small:
block(void* chan)— called by the running process. It recordschan, marks itselfBLOCKED, and gives up the CPU. It returns only when somebody wakes it.wakeup(void* chan)— called by whoever caused the event. It marks every process blocked onchanasRUNNABLE. Note that this does not switch to any such process that just became runnable; the code merely makes these processes runnable again.
chan is a wait channel: it allows filtering waiters by what they are waiting on. Any value that both sides agree on means “the event I care about”, and the address of the data structure involved is the conventional choice for the value to use, because it is guaranteed unique and is available on both the blocking side and on the event-handling side. For example, a process waiting for keyboard input blocks on &input_buf; the UART interrupt handler, having just pushed a byte into input_buf, calls wakeup(&input_buf).
There are two subtle things about wakeup that we’d like to warn you about:
- It wakes everyone on the channel, not one process. If three processes are waiting for a keystroke and one byte arrives, all three wake up, one gets the byte, and two go back to sleep. (This is called a “thundering herd”.) The reason to do it this way is that it is safe. A woken process must always re-check its condition anyway, because it cannot know whether the thing it wanted is still there by the time it runs.
- It does nothing at all if nobody is waiting.
wakeupis not a message and it is not remembered; it is a nudge to whoever happens to be asleep right now.
That second property leads directly to the classic bug in this area:
The lost wakeup. Consider this, which looks completely reasonable:
if (buffer_is_empty()) {
block(&input_buf);
}
c = pop();Now suppose a key is pressed in between the condition check in the if statement and the line that calls block. The interrupt handler pushes the byte and calls wakeup(&input_buf)—but this process is not blocked yet, so wakeup finds nobody and does nothing. A microsecond later the process blocks, on a channel that has already been signalled, and sleeps forever. The data is sitting right there in the buffer and the process will never look at it again.
There are two things you must do to avoid this issue:
- Make the check-and-block atomic with respect to the interrupt. The window between testing the condition and being marked
BLOCKEDmust not contain an interrupt. You already have the tools to make this happen: usedisable_interrupts()andrestore_interrupts()from Project 2. - Always re-check in a loop, never an
if. Writewhile (condition_not_met) { block(chan) };. Your code may be woken up spuriously, or another piece of code may have taken the data before your code runs. This is similar to the way you use condition variables for synchronizing threads in user-space programming: the wakeup is a hint, not a guarantee.
What you need to do
Part A: getc(), the wasteful version
In the next step, you will add functionality that allows your user programs to read input. We will build this in two steps, so that you can see a key problem before you fix it.
Task: Expose a getc() to user programs and write a program that uses it.
- Add two new slots to the kernel function trampoline:
F_GETCandF_HASC, following the convention you established in Project 2 forF_VPRINTFandF_YIELD. - Write a function
console_getc()(e.g., inkernel/console.c). For now, implement it the naive way: loop until the input buffer is non-empty, giving up the CPU each time round, then pop a byte and return it. Also writeconsole_hasc(), which returns how many bytes are currently available without ever waiting. - Install pointers to them in
F_GETCandF_HASCin your startup code (or in aklib_init()helper, if you made one), and add user-side wrappersgetc()andhasc()touser/lib/u_common.{c,h}, alongside the existingprintfwrapper. - Write a new user program,
user/keytest.c: this should be a program that prints a prompt, callsgetc(), and prints the character it got, in a loop. Load it ininit()and run it. - Delete the temporary drain you added to your timer interrupt handler in Quest 1.
Gotcha: be careful about how you give up the CPU.
“Giving up the CPU” in step 2 does not mean calling your kernel’s yield(). You have two functions involved in yielding, and they are subtly different:
yield()inkernel/scheduling.cis the internal function from Project 2 Quest 3. It marks the current processRUNNABLEand callsscheduler(). It is[[noreturn]]: it never saves a context, so a loop wrapped around it can never come back round.yield_entryis the assembly stub from the same quest, the one yourF_YIELDslot points at. It saves a context, records it, and then runs the scheduler—so when this process is next picked, theeretinrestore_contextlands on the instruction after the call and it returns like an ordinary function. This is what you want forconsole_getc: you want to give up the CPU and come back later, not yield forever.
Therefore, yield_entry is what you want and you can call it straight from C: declare it as void yield_entry(void); and call it. This will be important again in Part B.
Hints
user/u_klib.his generated byuser/gen_klib_header.py, which scrapes every#define F_...line out ofkernel/memlayout.h. AddingF_GETCthere is all you need to do to make the address visible to user programs—do not editu_klib.hby hand.- The user-side wrappers are one-liners in exactly the style of the
printfone you already have. The only fiddly part is the function-pointer cast. - Keep
primecheckloaded alongsidekeytest, so that there is always something for the scheduler to run. You will need that until Part C.
🤖 AI use/coding: mixed.
- Allowed, including agentic coding, for the trampoline plumbing (steps 1, 3 and 4).
- Not allowed for the body of
console_getc.
Why?
You built this exact mechanism twice already, in Projects 1 and 2, so there is no new learning in wiring up a third slot. But you should be able to say, without looking, what the sequence of events is when a user program calls getc()—which address it reads, what it finds there, and whose stack the resulting code runs on.
For context, see the principles around AI use in CS 1670.
Run it. It works! But it’s rather expensive: waiting for a keystroke burns a lot of CPU time. You can see this in the QEMU window: the timer interrupt is firing, the scheduler is running, and every time it picks your keytest process it discovers there is no pending input and immediately gives up the CPU again.
Part B: real blocking
Task: Add blocking to your kernel and make getc() use it.
- In
kernel/proc.h, addBLOCKEDto yourenum procstate, and a field tostruct procto record what the process is blocked on (e.g.,void* blocked_on). - In
kernel/scheduling.c, writeblock(void* chan)andwakeup(void* chan)as described in the background section above. - Fix your kernel’s
yield()so that it does not clobber theBLOCKEDstate. Right now, your implementation probably unconditionally sets the current process toRUNNABLE. Instead, it must leave a process that has just marked itselfBLOCKEDalone. - Rewrite
console_getc()to useblock(), with the interrupt masking and the re-check loop described in the danger box above. - In
console_handle_input(), callwakeup()on the input buffer’s address after pushing a byte.
Hints
- In most cases,
pick_next()needs no changes at all to skip blocked processes, provided it already looks only forRUNNABLEprocesses. - Your masking in
console_getcwill look slightly odd, becauseblock()does not return with the interrupt state you had when you called it.yield_entrymasks interrupts as its first instruction, and the eventualeretrestoresSPSR_PROC, which has them unmasked. The clean way to write the loop is to make a fresh call todisable_interrupts()at the top of each iteration, and onlyrestore_interrupts()on the path where you actually return a character. We recommend drawing the control flow for this out on a piece of paper! wakeupis called from an interrupt handler, so it must be short and must not block. ScanningNPROC= 16 entries is fine.- Add a
CHECK()inblock()that the current process isRUNNINGwhen it is called, and aCHECK()inpick_next()that the process it returns is not the current one unless it really is the only runnable process.
🤖 AI coding: NOT allowed for block(), wakeup(), console_getc(), and the change to yield(). Auxiliary use allowed for everything else, and for discussing the design.
You are, as always, welcome to ask AI to explain the concepts: “what is a wait channel”, “why must I re-check the condition in a loop”, or “explain the thundering herd problem” are all good questions to ask it.
Why?
This is the core of this part of the project. The necessary code is only about twenty lines in total—there is no productivity argument for generating it, and a large learning cost to not writing it. In particular, the lost-wakeup race is exactly the kind of thing you only really understand once you have had to reason about where to put the interrupt masking code, and an AI will simply hand you correct code with the function calls already in the right place, which harms your learning.
For context, see the principles around AI use in CS 1670.
Now run it again. The process blocks once and is woken exactly once, when the key arrives! You can verify this with some debug prints or in GDB.
Part C: the process that does nothing
Now try running only the blocking programs. Remove primecheck from your init() and load just keytest (plus anything else that blocks). Press a key, let keytest block again, and watch:
PANIC: pick_next: no runnable processThis is correct behavior from your scheduler: every process is blocked and there is nothing to run. Your pick_next() is quite right to panic!
Background: why an idle process, and not just “do nothing”?
You might reasonably ask why the scheduler cannot simply return, or spin in a loop inside itself, until something becomes runnable. It could—but look at what that would require. The scheduler is not a process; it has no context. It runs on whatever stack the last process was using. “Spinning in the scheduler” means the machine is sitting in some arbitrary process’s stack, registers, and identity while it waits. Any piece of kernel code that asks “who is running?” by looking at current_process gets a misleading answer.
The trick many operating systems use instead is to make “nothing to do” into just another process. The idle process is always runnable, does nothing useful, and exists purely so that pick_next() always has an answer. It is conventionally PID 0, and it is the reason PID 0 is special on Unix systems today.
Once it exists, a number of awkward special cases evaporate. The scheduler needs no “what if the answer is nobody” branch. There is always a current process, so your current-process pointer is never null. And the CPU is always executing something, which means the timer interrupt still fires, so a wakeup can still happen.
Real kernels do one thing yours will not, at least not by default: their idle loop executes an instruction that puts the core to sleep until the next interrupt, rather than spinning. On ARM64 that instruction is wfi (“wait for interrupt”).
Task: Add an idle process.
- Write
user/idle.c: a program whose entire body is a loop that callsyield(). - In
init(), create and load it first, so that it lands in slot 0 of the process table and gets PID 0. - Refine
pick_next()so that it only returns the idle process when there is genuinely nothing else: scan for aRUNNABLEprocess other than PID 0 first, and fall back to PID 0 only if you find none. - Re-run with only blocking programs loaded. There should be no panic: your OS should sit quietly waiting for you to type.
Hints
- Step 3 matters more than it looks. Without it, your round-robin scan will hand the idle process a full quantum on every trip around the table—so with three other processes, a quarter of your CPU goes to a program whose job is to do nothing.
- Keep it simple: a second pass, or a “remember the best candidate and keep looking” loop, is sufficient. Do not build a run queue unless you’re eagery for a lot of extra work!
- If you want to try
wfi, the idle loop becomesfor (;;) { asm volatile("wfi"); }. But think carefully before you write this code, because it interacts with your interrupt discipline. Interrupts must be unmasked around thewfi, though not for the reason you might expect: a pending IRQ wakes the core out ofwfieven whenPSTATE.Imasks it. The interrupt just isn’t taken, so a masked idle loop would spin waking and re-sleeping without ever running a handler. - Your idle process is a perfectly ordinary user program, loaded from an ELF like any other. There is nothing privileged about it; it is special only by convention and by living at PID 0. (In real OSes, like Linux, the idle process is privileged and runs in kernel mode, but that is not necessary for your OS.)
🤖 AI use/coding: auxiliary use allowed. The idle program itself is three lines. You may use AI to discuss how real kernels handle the idle case or to help you restructure pick_next.
Why?
There is almost no code here, but there is a real idea: that “the machine has nothing to do” is a state a scheduler must be able to represent, and that the cheapest way to represent it is as a process. We may ask you why your OS needs PID 0 and what would break without it.
For context, see the principles around AI use in CS 1670.
Part D: run a real program
You now have enough of an operating system to run something that was written for a real one. We provide user/basic.c: an implementation of Tiny BASIC, a descendant of the interpreter published in Dr. Dobb’s Journal in 1976 for machines with a few kilobytes of memory. It reads with getc() and writes with printf(), and it needs nothing else from your kernel.
Task: Go to this repository, which contains a set of user programs we provide for you. Copy basic.c into a file called basic.c in your user/ directory.
Then add it to your init() and use it. Type a program in and run it:
10 PRINT "HELLO FROM MY OS"
20 GOTO 10
RUNUnload keytest first. There is still only one input buffer in your kernel, so two programs that both call getc() will take turns stealing each other’s keystrokes, and you will not be able to type a line of BASIC. This is because your OS has no idea which program your typing is meant for; we’ll fix this in a later quest by adding the notion of a “foreground process” that owns the keyboard.
This Tiny BASIC is fussy about spaces inside expressions: PRINT 2*3 works and PRINT 2 * 3 is an “Invalid expression”. It is a 1976 design squeezed into a few kilobytes, and spaces were a luxury back then! (Recall that each space uses a byte of memory.)
Ctrl-C stops a running BASIC program. Note that this works even while your BASIC program is in a tight loop printing, and notice exactly why: the UART interrupt deposits the byte in your buffer no matter what any process is doing, and the hasc() you added in Part A lets BASIC check for it without blocking.
Load primecheck alongside the BASIC interpreter, and keep an eye on its output on the serial console while you type. You now have a machine that is simultaneously interpreting a language you are typing into and computing prime numbers!
🤖 AI use/coding: allowed, including agentic coding—though there is nothing to write: we provide the program. (You are welcome to read it. It is a nice piece of 1970s C.)
Part E: who gets the keystroke?
You can now run BASIC—but not BASIC and anything else that reads the keyboard, because two readers take turns stealing each other’s bytes. Every OS that runs more than one interactive program has had to solve this, and all solutions turn out to be variants of the same basic approach.
Background: one keyboard, many processes
The screen can be divided between processes; you will do that in Quest 4. The keyboard cannot: there is one stream of bytes, and there is no sensible way to give each process a fraction of it. So exactly one process at a time has the focus—it is the foreground process—and the input goes there.
The decision of which process is the foreground process has to live somewhere, and it must be the kernel’s, because it is the kernel that owns the input device. Your design will be:
- Each process gets its own input ring buffer, in its process descriptor. A process’s
getc()reads from its buffer and blocks on its buffer’s address. - Your kernel remembers a
foreground_pid. When a byte arrives, the UART interrupt handler pushes it into that process’s buffer and wakes that process. - One reserved key is not delivered to anyone: it is a command to the kernel to move the focus on to the next process.
- Only processes that actually read the keyboard can have the focus. Your kernel can tell which those are without being told: they are the ones that have called
getc()orhasc(). Giving the focus to anything else—the idle process, say—would make everything you type silently vanish.
Why per-process buffers, rather than one buffer plus a rule?
You could keep the single kernel buffer from Quest 1 and simply arrange that only the foreground process reads from it. It would even mostly work. But think about what happens when you switch focus: process A is blocked on the shared buffer with three characters of a half-typed command sitting in it, and now B is supposed to read. Whose characters are those?
Per-process buffers make that question disappear. Each process’s half-typed line stays with it, exactly the way switching windows on your laptop leaves each application’s text field as you left it. It also means a background process that was sent input before it lost focus still has it waiting when it is scheduled. The state belongs to the process, so it should live in the process descriptor—which is the same reasoning that put the context, the stack pointer, and the PID there.
Background: a key is not a byte
Which key should move the focus? The obvious candidates are the ones your laptop uses—Alt+Tab, or Command+Tab on a Mac. But neither can work for us here: you laptop’s OS will intercept these keys before they even reach the UART when you develop on your machine. (Interestingly, these keys would work if you connnected a real terminal, like the VT100, to a physical Raspberry Pi; but that doesn’t make for a very convenient development environment.)
It’s important to realized that your OS never sees an actual keyboard device. It sees the bytes that something sends down the UART serial line (see “Where is your keyboard, exactly?” above), with that something likely being a terminal program (a “terminal emulator”) on your computer. Command+Tab, and on most systems Alt+Tab, are taken by your laptop’s own operating system to switch between its windows. Your terminal emulator never sees these keypresses, so it can’t send them on the emulated UART connection to your OS in QEMU.
The keys that do get through to QEMU and your OS arrive as bytes, and most of them as exactly one: a is 0x61, Enter is 0x0D, Ctrl-C is 0x03. But a keyboard has many keys that have no character of their own—e.g., arrows, function keys, Home and End—and a terminal sends each of those as an escape sequence: the byte 0x1B (ESC), then [, then one or more further characters. The up arrow is ESC [ A. Shift+Tab is ESC [ Z (“back-tab”, in terminal jargon). The convention comes from the DEC VT100 terminal of 1978, was standardized as ANSI X3.64, and is still spoken by every terminal program you are likely to use—Terminal.app, iTerm2, the VS Code terminal, Windows Terminal, to name a few.
We recommend that you use Shift+Tab to move the focus: it reaches your OS, nothing in your OS uses it for anything else, and it is close to what moving the focus means elsewhere (in most graphical programs, Tab and Shift+Tab move the focus between controls). The price is that your kernel has to recognize a three-byte sequence, which may arrive in three separate interrupts, while passing every other escape sequence through untouched, so that a program that understands arrow keys still gets them.
Task: Look before you build. Load keytest, then press Shift+Tab, an arrow key, and Escape, and write down exactly which bytes arrive for each.
🤖 AI use/coding: allowed, including agentic coding—though there is nothing to write here. Do look at the bytes yourself: that a key is a sequence of bytes is the whole point.
Task: Give each process its own input, and let the user choose who is listening.
- Move the input ring buffer from a global in
console.cintostruct proc. Initialize it inallocproc(). - Change
console_getc()andconsole_hasc()to use the current process’s buffer, and to block on its address. - In both, record that the current process reads the keyboard (a
boolinstruct procwill do). If no process has the focus yet, give it to this one. - Add a
foreground_pidtoconsole.c, initially “nobody”. A byte that arrives while nobody has the focus has nowhere to go; dropping it is fine. - Rewrite
console_handle_input(char c)as a small state machine. In the normal state, anESCmeans “this might be Shift+Tab”: remember it and return. After anESC, a[means “it still might be”; afterESC [, aZmeans it was—move the focus on to the next process that reads the keyboard, and deliver nothing. Anything else means it was not Shift+Tab: deliver the bytes you held back, in order, and then deal with the new byte. To deliver a byte, push it into the foreground process’s buffer and callwakeup()on that buffer’s address. - Make every switch visible: print which process now has the focus on the serial console.
- Load
idle,keytestandbasic, and switch between them.
Hints
console_handle_inputruns in an interrupt handler, so keep the state machine’s state in astaticvariable, and keep it short. Note that it is the only place in your kernel where a policy decision (who gets this byte?) is made at interrupt time. What would you have to change to move that decision out of the handler?- Moving the focus on means scanning the process table for the next process after the current foreground one that reads the keyboard, wrapping around as necessary.
- A lone press of Escape leaves your state machine waiting for a
[that never comes, so the Escape reaches the program only when the next key is pressed. Nothing in your OS uses Escape, so this is harmless. Real terminal software has exactly this problem and solves it with a timeout—which is whyvimhas a setting calledttimeoutlen. - A
getpid()call is handy here—use one more trampoline slot—so thatkeytestcan say which process it is. Load two copies of it and switch between them. - Two processes can now be blocked on two different channels at once. Check that: break in
gdband print both processes’blocked_onfields. They should be different addresses, both inside their respectivestruct proc. - If keystrokes go nowhere after your change, check that
allocproc()actually initializes each new buffer—a process table entry that wasUNUSEDcontains whatever the last process left there.
🤖 AI use/coding: auxiliary use allowed. You may use AI to help with the escape-sequence state machine and to debug the buffer plumbing. You may not have it implement the routing decision for you.
Why?
“There is one keyboard and several processes; who gets the keystroke, and where is that decided?” is a learning objective of this project. Implementing this right takes about fifteen lines of code, so it’s not a huge effort but comes with a lot of conceptual understanding. We may ask you to trace, from the key press to the character arriving in a program’s variable, what happens to handle interactive input.
For context, see the principles around AI use in CS 1670.
Check your work
With idle and basic loaded—and nothing that never blocks, such as counter—make qemu should give you a machine that:
- Sits quietly when you are not typing. To test this, add a
printfto your idle process (rate-limited—say, every 100,000 iterations). This should become the only thing that runs once everything else has blocked. - Responds instantly when you do type. If a program never reacts at all, you are not calling
wakeupfrom the interrupt handler: aBLOCKEDprocess is never picked again on its own, so it stays asleep forever. Note that up to one quantum of lag is normal and correct even when everything works—wakeuponly marks the processRUNNABLE, and the handler then resumes whoever it interrupted. Making the keystroke act immediately would mean rescheduling from inside the interrupt handler, which is a different design than what we recommend above (though it can also be made to work). - Never panics with “no runnable process”. Try to make your OS panic: block everything you can, hold keys down, and paste large amounts of text.
Then load keytest alongside basic, and check that:
- Focus switching works both ways. Press Shift+Tab until you are back where you started. Type into each program in turn and confirm that the characters go where you expect—and, crucially, that a line you half-typed into one program is still there when you come back to it.
- Everything else still gets through. In
keytest, an arrow key should still arrive as its three bytes, and the idle process should never be offered the focus.
We recommend two checks in gdb to see this is working correctly:
(gdb) break wakeup
(gdb) continue
# ...now press a key...
(gdb) p current_process->name # who got interrupted? (whoever happened to be running)
(gdb) p processes[1].state # the blocked process: BLOCKED before...
(gdb) finish
(gdb) p processes[1].state # ...and RUNNABLE afterAnd, to see the shape of the whole thing:
(gdb) break block
(gdb) continue
(gdb) bt # a stack trace through getc, through the trampoline,
# and into the user programThat backtrace should show kernel code and user code interleaved on a single stack. This is because kernel code is invoked as library code from user programs here; this will stop being true in Project 4.
Bonus Quests (Extra Credit)
Important note: All extra credit work requires you to meet with an instructor at office hours to discuss your work in order to get credit. There will be no exceptions to this, since extra credit is strictly optional.
Quest 2-E1: Terminal Control Characters (moderately difficult)
Your terminal sends more than letters. Depending on your terminal, its manufacturer, and the keys its keyboard has, the following special byte sequences might be sent when a key is pressed:
- Arrow keys send three-byte escape sequences beginning with
ESC(0x1B); Ctrl-Csends0x03;- backspace sends
0x7For0x08(depending on the terminal).
Right now your kernel treats all of these as ordinary bytes and just delivers them to processes. But you can do better: these are all control characters, and your kernel can recognize them and act on them in a way that is useful to the user.
Task (extra credit): Write a terminal or text editor application in which the user can delete text using backspace, navigate using arrow keys, and use other keys that send control characters to access functionality.
You should aim to support ANSI escape sequences for cursor movement and text formatting, which are widely supported by terminal emulators (in a large part because the VT100 supported them). You can also implement your own control characters for additional functionality.
For this task, you’ll need to think about and answer several design questions:
- What terminal funcitonality exists in the kernel and what is implemented in the user program? For example, should the kernel handle backspace and arrow keys, or should it just deliver them to the user program? Does the user program keep track of the cursor position, or does the kernel do that?
- How do you handle multi-byte escape sequences that arrive one byte at a time, possibly split across two interrupts?
- How does the kernel know the size of your terminal’s screen (or the paper width for a printer-based terminal)?
- What control characters are meaningful on a printer-based terminal, such as the DECwriter? How is that different from a video terminal, such as the VT100?
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 1-E2: Fun with the VT100 (variable difficulty)
The DEC VT100 we saw in class is a classic video terminal. It appears as a serial UART device, and displays any characters it receives on the screen, while sending back a stream of characters for every key the user presses. It is the ancestor of every terminal program we use today! (In fact, your terminal app is emulating a VT100.)
For this extra credit, do something impressive with the VT100. You can use materials like the VT100 control character documentation and the full VT100 manual (PDF, HTML version) to understand how it works and what is possible.
Some ideas:
- Investigate the VT100 “Special Graphics Characters” and use them to draw boxes on the screen.
- Learn about the VT100’s text formatting support (bold, underline, inverse video, etc.) and use it to make a text-based game or application.
- Make a snake game that is playable on the VT100.
- Create a terminal application that supports scrolling, both when the user’s input reaches the bottom of the screen and when the user presses a key to scroll up or down to look at older text.
- Support multiple virtual terminals on the VT100 that the user can switch between using a key combination (e.g.,
Ctrl-Alt-F1,Ctrl-Alt-F2, etc.).
… but these are just some ideas. You can do anything you want, as long as it is fun! Impress us :-)
One important thing to note is that the VT100 is quite slow: it can only send and receive characters at a rate of up to 9600 baud (or 960 characters per second). This means that if you want to do something like a game, you will need to be careful about how much output you send to the terminal, and how often you update the screen. In actual fact, we’ve found that our VT100 is most reliable only at 1200 or 2400 baud. Think about how you’d redraw only parts of the screen that have changed, rather than the whole screen, to avoid overwhelming the terminal.
Task (extra credit): Do something impressive with the VT100. Come to instructor office hours to try your ideas on the real terminal hardware. You can use QEMU to test your code, since your computer’s terminal is just emulating a VT100, but the real hardware is much slower and has some quirks that modern terminals don’t always emulate accurate.
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 3: Pixels
Files: kernel/drivers/mbox.c, kernel/drivers/mbox.h, kernel/drivers/fb.c, kernel/drivers/fb.h, kernel/drivers/timer.c, kernel/drivers/timer.h, kernel/memlayout.h, kernel/kernel.c, kernel/klib.c, kernel/init.c, user/font8x8.h, user/lib/u_common.c, user/lib/u_common.h, user/lib/u_fb.c, user/lib/u_fb.h, user/lib/u_string.c, user/lib/u_string.h, user/lib/u_term.c, user/lib/u_term.h, user/logo.c, user/term.c, user/pong.c (provided).
Pulling in new stencil files.
You will need to pull in the new files for the second part of this project (new empty source files, the provided code, and an updated build system), run the following in your project directory:
git pull handout mainIf this reports an error about an unknown remote, run:
git remote add handout git@github.com:csci1670/cs1670-f26-projects.gitand then git pull handout main again.
Important note about your workflow: For the rest of this project, you will be working in two windows at once:
- Your terminal, which is connected to your serial console
via UART. This displays any messages you print with
printf, and is where you provide input. Be sure to look back at your terminal for feedback on any errors/panics/etc. - Your graphical display, which shows the framebuffer
A somewhat non-intuitive consequence of this setup is that you must type any input into your terminal window (i.e., the serial console), not the graphical window. We’ve built our OS to accept input via UART, which you provide via the terminal. QEMU connects the graphical display to a “virtual keyboard” via USB rather than UART, so we’d need to implement a whole different driver to use it! Implementing USB is well beyond the scope of this project (and our course), so instead, just be sure to switch to your terminal before trying to type.
Overview
Time to make your OS visible. In this quest you will:
- Ask the Raspberry Pi’s GPU for a framebuffer, using a message-passing protocol called the mailbox, and publish where it lives so that user programs can find it.
- Write a user program that draws something—your OS’s logo—and see it on a screen.
- Build a bitmap font renderer, discover that text is nothing but a small array of bits per character, and design a character of your own.
- Write a text console—the code that turns a stream of characters into text on the screen—and a terminal application on top of it: an ordinary user program that reads your keyboard and draws what you type, with a few built-in commands.
- Run a provided graphical program—a bouncing box—and watch it tear.
This is the longest quest in the project, but also the one with the most immediate feedback: from Part B onward, you can see whether your code works.
Why are we doing this?
A framebuffer is a simple, but powerful piece of output hardware and also the foundation of every display you use today. Beneath the window system, the GPU driver and the font engine on your laptop, there is a region of memory that just contains pixel values that make up what appears on screen. It works exactly like the framebuffer you are about to set up!
There is also a specific way of interacting with hardware in this quest that the UART did not teach you. The PL011 is a device you talk to by reading and writing registers. The Raspberry Pi’s VideoCore GPU is a different processor, running its own code, that you talk to by sending it messages. Increasingly much modern hardware works this way—GPUs, network cards, storage controllers, and the management processors inside server CPUs—and requires a shift from thinking about I/O as “poke a device register” to “compose a request and wait for a reply”.
Background: who owns the screen?
Recall from Project 1 that the “Raspberry Pi” is really a Broadcom VideoCore chip that happens to have four ARM cores attached, and that the VideoCore is a graphics processor.
As a consequence, the display hardware belongs to the GPU, not to the ARM CPU. There is no “framebuffer base address” register you can write. There is no documented framebuffer in the BCM2835 peripheral manual at all. The GPU’s firmware owns the display controller, owns the memory the display controller reads, and will give you a framebuffer only if you ask for one.
The mechanism for asking is called the mailbox, and it is a small message-passing channel between the ARM cores and the GPU. You write the address of a request buffer into one register; the GPU reads it, acts on it, writes results back into your buffer, and posts a reply. It is a remote procedure call (RPC) implemented through a set of device registers.
Where is the mailbox documented? Not in the BCM2835 peripheral manual, which describes the registers but not the protocol, and not in any ARM document. The authoritative description of the property interface is a page on the Raspberry Pi firmware wiki, maintained by the Raspberry Pi Foundation.
Poor documentation for proprietary hardware is an unfortunate fact of life when writing OS kernels. Check out Appendix A for what to read for this project.
Background: the mailbox property interface
The protocol has two layers.
The transport is three MMIO registers at PERIPHERALS_BASE + 0xB880:
| Register | Offset | Role |
|---|---|---|
MBOX_READ | +0x00 | Read a reply from the GPU. |
MBOX_STATUS | +0x18 | Bit 31 (FULL) being set means “cannot write”. Bit 30 (EMPTY) being set means “nothing to read”. |
MBOX_WRITE | +0x20 | Write a request to the GPU. |
A message is a single 32-bit word whose low 4 bits are a channel number and whose high 28 bits are the physical address of a request buffer. Channel 8 is the “property” channel, which is the one we want. Because the low four bits are stolen for the channel, the buffer must be 16-byte aligned. In C, this means you’ll want to add __attribute__((aligned(16))) on its declaration.
To send, your code spins until STATUS says not-full, then write the message word to MBOX_WRITE, then spins again until STATUS says not-empty and reads MBOX_READ until it reads its own word back, which confirms the GPU received and processed the request.
The payload in the request buffer is an array of 32-bit words laid out as a list of tags:
[0] total size of the whole buffer, in bytes
[1] request/response code (0 on the way out; `0x8000'0000` means success on the way back)
[2] tag identifier ─┐
[3] size of this tag's value buffer, in bytes │ one tag
[4] request/response code │
[5..] the value words ─┘ (the GPU overwrites these with its response)
... more tags ...
[n] 0x00000000 terminates the listThe GPU processes the tags in order and writes each one’s results back into the same memory buffer your code sent it. So a single message can say “set the resolution to 640x480, set the depth to 32 bits, now allocate me a framebuffer and tell me where it is and how wide a row is”.
The tags you need are the following:
| Tag | Value | Sends | Receives |
|---|---|---|---|
| Set physical width/height | 0x00048003 | width, height | (echoed) |
| Set virtual width/height | 0x00048004 | width, height | (echoed) |
| Set virtual offset | 0x00048009 | x offset, y offset | (echoed) |
| Set depth | 0x00048005 | bits per pixel | (echoed) |
| Set pixel order | 0x00048006 | 1 for RGB | (echoed) |
| Allocate buffer | 0x00040001 | requested alignment | base address, size |
| Get pitch | 0x00040008 | — | bytes per row |
Physical vs. virtual size, and why we set both
The GPU distinguishes the physical size (how many pixels the display shows) from the virtual size (how big the buffer in memory is). If the virtual buffer is larger than the physical display, the GPU shows a window into it, positioned by a “virtual offset” you can set.
That is a hardware feature to support something called “panning”, and for now we make virtual and physical resolution identical. In Quest 4 you will make the virtual buffer twice as tall as the display and flip between its two halves, which will help you get a tear-free display.
Bus addresses versus physical addresses. The base address the GPU hands back is a VideoCore bus address, not an address your ARM core can use. The top two bits are an alias selector that means something to the GPU but not the ARM CPU. Mask them off by computing fb_base & 0x3FFF'FFFF to get the ARM physical address that the CPU understands.
If you skip this masking step, you will get a pointer somewhere around 0xC000'0000, writes to it will go nowhere, and your screen will stay black with no error of any kind.
What you need to do
Part A: ask for a framebuffer
We provide you with the transport layer for sending messages to the GPU in the form of mbox_call() in kernel/drivers/mbox.c. This function takes a 16-byte-aligned buffer, sends it on channel 8, and returns whether the GPU reported success. You write the part that builds the request and, more importantly, the part that makes the result usable by user programs.
The result of fb_init() is a handful of numbers—where the pixels are, how big the screen is, how many bytes a row takes—and every program that wants to draw needs all of them. You already have a mechanism for handing things to user programs: the fixed-address convention you built in Project 1 for printf. Do the same thing here, with a whole struct rather than a function pointer. Define a struct fb_info, agree on an address for it (FB_INFO_BASE in kernel/memlayout.h), have the kernel write it there at boot, and have user programs read it from there.
A reasonable struct fb_info for this quest:
struct fb_info {
uint32 width; // visible width in pixels
uint32 height; // visible height in pixels
uint32 pitch; // bytes from the start of one row to the start of the next
uint32 pitch_w; // the same thing in units of uint32 (pitch / 4), for convenience
uint32 depth; // bits per pixel
uint32* pixels; // where to draw. NULL means "no framebuffer"
};Task: Give your kernel a framebuffer.
- Decide on a resolution (640x480 or 1280x720 are good choices) and a color depth (use 32 bits per pixel) for your framebuffer. Put these as constants in
kernel/drivers/fb.h. - Pick an address for
FB_INFO_BASEand add it tokernel/memlayout.h. It must not collide with anything you already use: your kernel image, your per-process memory regions, or yourF_BASEtrampoline area. Make sure to correctly calculate the size of the frambuffer: each pixel is 4 bytes (32 bits) and your resolution determines how many pixels you have. Update your memory layout diagram to reflect your choice. - Write a function to set up the framebuffer (e.g.,
fb_init()inkernel/drivers/fb.c): build the property message to request a framebuffer from the GPU, callmbox_call(), convert the returned bus address to a physical address, and fill in thestruct fb_infoatFB_INFO_BASE. On failure, setpixelstoNULLand return an error, rather than panicking: your OS should still boot and run on the serial console when there is no display. - Call
fb_init()fromkernel_main, and print what you got: the resolution, the pitch, and the framebuffer address. - Prove it works before you write any drawing code: right after
fb_init()succeeds, fill the entire framebuffer with a single color, and runmake qemu-fb(ormake qemu-ws).
Just give me the property message code...
Here is a request buffer that sets up a 640x480, 32-bit framebuffer and retrieves its address and pitch. It is very specific to Raspberry Pi and tedious to write, so we give it to you.
// Must be 16-byte aligned: the low 4 bits of its address encode the channel.
static volatile uint32 __attribute__((aligned(16))) mbox_buf[36]; // 35 used
int fb_init(void) {
mbox_buf[0] = 35 * 4; // total size of this buffer, in bytes
mbox_buf[1] = MBOX_REQUEST; // 0 == "this is a request"
mbox_buf[2] = MBOX_TAG_SET_PHYS_WH; // set the display resolution
mbox_buf[3] = 8; // value buffer is 2 x uint32
mbox_buf[4] = 0;
mbox_buf[5] = FB_WIDTH;
mbox_buf[6] = FB_HEIGHT;
mbox_buf[7] = MBOX_TAG_SET_VIRT_WH; // set the in-memory buffer size
mbox_buf[8] = 8; // (identical, for now; Quest 4 changes this)
mbox_buf[9] = 0;
mbox_buf[10] = FB_WIDTH;
mbox_buf[11] = FB_HEIGHT;
mbox_buf[12] = MBOX_TAG_SET_VIRT_OFFS; // no panning
mbox_buf[13] = 8;
mbox_buf[14] = 0;
mbox_buf[15] = 0; // x offset
mbox_buf[16] = 0; // y offset
mbox_buf[17] = MBOX_TAG_SET_DEPTH;
mbox_buf[18] = 4;
mbox_buf[19] = 0;
mbox_buf[20] = FB_DEPTH; // 32 bits per pixel
mbox_buf[21] = MBOX_TAG_SET_PIX_ORDER;
mbox_buf[22] = 4;
mbox_buf[23] = 0;
mbox_buf[24] = 1; // 1 == RGB
mbox_buf[25] = MBOX_TAG_GET_FB; // allocate the framebuffer
mbox_buf[26] = 8;
mbox_buf[27] = 0;
mbox_buf[28] = 4096; // request 4 KiB alignment;
mbox_buf[29] = 0; // ...the GPU replaces [28] with the bus address
// and [29] with the size in bytes
mbox_buf[30] = MBOX_TAG_GET_PITCH; // ask how many bytes a row takes
mbox_buf[31] = 4;
mbox_buf[32] = 0;
mbox_buf[33] = 0; // ...the GPU replaces [33] with the pitch
mbox_buf[34] = MBOX_TAG_LAST; // end of tag list
if (!mbox_call(mbox_buf)) {
// TODO: zero the struct at FB_INFO_BASE (in particular, pixels = NULL)
// before returning, so that user programs can tell there is no display.
return 0; // the GPU said no
}
// TODO: mbox_buf[28] is a *bus* address; mbox_buf[33] is the pitch.
// Turn them into a struct fb_info at FB_INFO_BASE.
}I want to build the message myself!
Sure! The protocol is interesting, but very specific to the Raspberry Pi. Read the property interface wiki page, which documents the buffer layout and every tag. Four things often go wrong at this point:
mbox_buf[0]must be the size of the whole buffer in bytes, including the header and the terminating zero—not the number of tags, and not the size in words.- Each tag’s size field is the size of its value buffer (the words after the tag header), in bytes, and it must be large enough for the response, which may be bigger than the request. The allocate-framebuffer tag sends one word and receives two, so its size is 8.
- The terminating
0tag is required. Without it, the GPU walks off the end of your buffer. - Check the response code in
mbox_buf[1](0x8000'0000= success) and check that the values you got back are non-zero.
Hints
- Print everything
fb_init()learns from the GPU.width,height,pitch, and the framebuffer address are very helpful for debugging subsequent parts of this quest. - Compare
pitchtowidth * 4. They may be equal on QEMU and different on real hardware, or vice versa. If you are tempted to usewidthas the row stride because “they’re the same anyway”, resist: it is a common framebuffer bug and produces a beautifully diagonal image. - Make
struct fb_infoa shared definition: the kernel and user programs must agree on the layout exactly. The simplest approach, and the one we suggest, is to write it once inkernel/drivers/fb.hand once inuser/lib/u_fb.h, and keep the two in sync by hand. (Yes, duplicated struct definitions are a bad idea in general. A real OS solves this with a shared “uapi” header; the reason you cannot simply#includethe kernel header is that user programs are compiled separately and must not depend on kernel internals.) - Duplicate
FB_INFO_BASEby hand too. TheF_...slots get copied intouser/u_klib.hfor you, butgen_klib_header.pymatches literally#define F_—soFB_INFO_BASEwill not be picked up, and a user program that mentions it will not compile. Either hard-code the same address inuser/lib/u_fb.h(simplest, and what we assume), or rename the constant so it fits the generator’s pattern. Whichever you pick, a mismatch between the two copies means your programs read astruct fb_infofull of garbage. - If the screen is black, print the framebuffer pointer and check it with
x/4wxingdbafter you have written to it. If your writes are landing in memory but nothing appears, you almost certainly forgot to mask the bus-address bits. - QEMU’s display window may open very small. Drag its bottom-right corner to resize, as in Project 0.
🤖 AI use/coding: allowed, including agentic coding, for building the property message (we give it to you anyway). Auxiliary use allowed for the struct fb_info design, the address you choose for FB_INFO_BASE, and the bus-to-physical conversion.
Why?
The tag list is a data structure transcribed from a wiki page; there is nothing to learn from typing it twice. But where the framebuffer information lives, and how a user program gets at it, is a decision about your OS’s interface to its processes. You should be able to explain why FB_INFO_BASE is where it is.
For context, see the principles around AI use in CS 1670.
Task: Update your memory layout diagram to show two new things: the struct fb_info at the address you chose, and the framebuffer itself at the address the GPU gave you.
Note something interesting about the second one: it is the first region in your memory layout that you did not choose. Another processor in this machine allocated it, in a part of physical memory that is reserved for the GPU, and told you about it afterwards. Write the address down in your diagram and mark where it came from.
🤖 AI use/coding: NOT allowed.
Why?
As in Projects 1 and 2, we want you to maintain your own mental image of your OS’s memory layout—and this project adds several new regions to it.
For context, see the principles around AI use in CS 1670.
Part B: draw something
Now, let’s put a program on the screen! Our first program will just be one that shows the OS logo.
Task: Write user/logo.c, a program that draws your operating system’s logo (or, if you prefer, something abstract and colorful).
- Start
user/lib/u_fb.handuser/lib/u_fb.c, the drawing library your programs will share. For now it needs two functions: one that fills a rectangle with a color, and one that sets a single pixel. You could add some named color constants, too.
Make both functions draw on a canvas rather than on “the screen”. Create a small struct for the canvas that holds a pixel pointer, a width, a height, and the distance from one row to the next (in pixels). For now, every program makes one canvas that covers the whole screen, filled in from struct fb_info. In Quest 4, the kernel will hand each process a canvas of its own, and if your library already draws on canvases, your code continues to work.
- Write
user/logo.c. It should readstruct fb_infofromFB_INFO_BASE, check thatpixelsis notNULL(and print a helpful message and yield forever if it is, so that the program is harmless on a machine with no display), make a canvas from it, and draw. - Load it in
init()and runmake qemu-fb.
Be as creative as you like. A flag of blocks of color, or a plot of something your OS computed (you still have pi.c from Project 1 lying around), both work well. Another option is to use GIMP to make a small image, and export it as a C file (yes, that is something Gimp supports!), and then edit the code to work with your OS.
- Head to EdStem and post your OS name and logo to our “OS Logo Gallery” mega thread! Include a screenshot of your QEMU display window.
Hints
pixels[y * pitch_w + x] = color;is the whole of graphics programming at this level. Everything else is just loops!- A pixel is
0xAABBGGRR, as in Project 0. Set the top byte to0xFF. So red is0xFF0000FFand blue is0xFFFF0000—note that the last two hex digits are red, rather than blue. - Bounds-check while you develop. A
CHECK(x < fb->width && y < fb->height)inside your pixel-setting function will come in handy. It catches bugs where you scribble past the end of the framebuffer and into whatever memory follows it. - If your program draws once and stops, it must still not fall off the end of
main—your processes cannot exit (unless you implemented the relevant extra credit in earlier projects). End with a loop that callsyield().
🤖 AI use/coding: allowed, including agentic coding.
Why?
This task is basically glorified setting of pixel values, which quickly becomes tedious to do manually (though that is certainly possible and people did so in the 1980s!). Have fun with it, but the crativity should be yours even if the execution is partly done by AI. The main thing that matters here is that you see your own pixels on your own screen.
For context, see the principles around AI use in CS 1670.
What our programs assume, and what they don’t. Two of the graphical programs you will run are ours: pong.c in Part E, and clock.c in Quest 4. Since we ship them before you have written your library, they cannot depend on it: each one does its own drawing and, to begin with, needs two things from you—the struct fb_info layout (its first six fields, in the order shown above) and the FB_INFO_BASE address you chose in Part A.
Everything else these programs need is isolated in a short wiring section at the top of each file: where to draw, what to do when a frame is finished, how to wait for the next frame, and where to get the time. Each one is written to work as-is in Quest 3 and to become a short change in Quest 4, and each says in a comment what to replace it with. Read that section before you run either program.
So: your drawing library is entirely your own design. Nothing we ship dictates its function names, its colors, its scaling, etc.
Part C: text is pixels
You have a screen and can draw graphics! But how would you display text? The hardware takes pixels and nothing else, so something has to turn a character into a shape, since the display doesn’t know anything about text. What controls text appearance is a font. (Note that on a terminal like the VT100, the font is in the terminal hardware itself, not on the computer. This is why a VT100 can only show text and only in a limited set of styles.)
Background: bitmap fonts
The simplest possible font is a so-called bitmap font. For each character code, this font stores a small grid of bits that indicate which pixels are on and which are off. We provide one such font in user/font8x8.h: 128 characters, each 8 pixels wide and 8 pixels tall, so each character is 8 bytes. This means there is one byte per row, and one bit per pixel. Our provided font is a public-domain font derived from the IBM PC’s original font baked into its ROM! (It was in the ROM in order to be able to display errors during bootup.)
Here is the letter A, with each byte written out in binary next to the row it draws:
font8x8_basic['A'] = { 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00 }
byte binary pixels drawn
(bit 0 is on the LEFT)
0x0C 0b0000'1100 . . # # . . . .
0x1E 0b0001'1110 . # # # # . . .
0x33 0b0011'0011 # # . . # # . .
0x33 0b0011'0011 # # . . # # . .
0x3F 0b0011'1111 # # # # # # . .
0x33 0b0011'0011 # # . . # # . .
0x33 0b0011'0011 # # . . # # . .
0x00 0b0000'0000 . . . . . . . .Read the binary column right-to-left to get the pixel column, because the least significant bit is the leftmost pixel: 0x0C has bits 2 and 3 set, so pixels 2 and 3—counting from the left—are on.
That is a convention, not a law. Fonts in other formats do it the other way round, so if you use a custom font, you may find that it renders as a mirror image!
Rendering a character is then two nested loops over an 8x8 grid, setting a pixel wherever the bit is 1. Rendering a string is a loop over characters, advancing the x position by 9 pixels (8 for the glyph, 1 for a gap) each time. For a new line, advance y by 10 pixels (8 for the glyph, 2 between lines). Those two numbers—9 across, 10 down—are what turn a pixel rectangle into a character grid, and your text console will need them in Part D.
Task: Make your OS able to draw text.
- In your drawing library, write
draw_char(): given the framebuffer, a position, a character, and a color, draw that character’s glyph. Then write adraw_string()on top of it. - Add a
scaleparameter, or a second function that takes one, which draws each font pixel as a scale x scale block. This only requires a few lines and allows you to have different font sizes! - Use it: have
user/logo.cwrite your OS’s name under its logo, at a large scale.
Hints
- Guard against characters outside the table:
if ((unsigned char)c > 127) return;. A stray high byte from your keyboard reading off the end of the font array can cause nasty bugs in Part D. - Test with
draw_string(..., "ABCdef 0123 !?", ...)before you trust it. If the characters come out mirrored, you have the bit order backwards; if they come out sheared, you are usingwidthinstead ofpitch_w; if every character is a solid block, you are testingglyph[row]for non-zero instead of testing one bit. - Draw the text in a color that contrasts with whatever is behind it. Your background is whatever the GPU left there at boot, which is often not black.
🤖 AI coding: NOT allowed for draw_char. Auxiliary use allowed for the rest.
Why?
This is ten lines of code, and it pulls everything together: how text appears on your computer screen, how the VT100 shows you text on a screen, and how graphics and text are actually the same at the low level of pixes. If you generate this code, you will have a working text renderer and no particular sense that you have understood anything (nor will you be able to explain it in a whiteboard discussion).
For context, see the principles around AI use in CS 1670.
Task: Design a character of your own!
Pick an unused slot in the font table—the control characters at 0x00–0x1F are all blank, or you can extend the array past 127—and fill in eight bytes that draw something you designed. A smiley face is traditional, but you could also do a heart or a set of box-drawing characters (lines, corners) that you can build a window frame out of in Quest 4’s extra credit.
Do this the old-fashioned way: draw an 8x8 grid on paper, shade in the pixels you want, and read off eight binary numbers. (Remember that bit 0 is the left.) Write them in C as binary literals—0b00111100—so the shape stays visible in the source code. That is how font tables were actually maintained back in the day!
Then draw your character on the screen next to your logo, and post a picture to the EdStem “Character Gallery” megathread.
🤖 AI use/coding: auxiliary use allowed, but we suggest you don’t use it at all.
Why?
An AI can produce eight plausible hex bytes for “a smiley face” instantly, but what fun is that? Do it on paper.
If you want a good use of AI here, ask it how antialiased and outline (vector) fonts differ from bitmap fonts, and what problem each was invented to solve.
For context, see the principles around AI use in CS 1670.
Part D: a terminal
You have input (Quest 2) and you have text output (Part C). Put them together and you have the oldest interactive program in computing: a terminal emulator. (It’s an emulator because it’s a program on your computer, rather than a hardware terminal attached to it.)
The terminal emulator is the program that reads characters from the keyboard, interprets them, and draws them on the screen. In a modern computer, the terminal emulator is host to a “shell” (e.g., Bash or Fish), which is a program that knows how to read and run commands.
Terminal vs. Shell. You are building a program that reads characters, echoes them to the screen, and recognizes a few commands. You are not building a shell in the Unix sense: a real shell’s main job is to start other programs and connect their inputs and outputs to things, and that needs fork, exec, and file descriptors, none of which your OS has. You will build those in Projects 4 and 6, and at that point this program can grow into a real shell.
For now, think of it as the built-in command monitor that shipped in ROM on early home computers and still ships in the firmware of most embedded systems: a prompt, a line editor, and a handful of commands that are compiled into the program itself.
We’ll build our terminal emulator in two parts:
- You’ll make a console library for drawing text
(
user/lib/u_vterm.h,user/lib/u_vterm.c): that is, starting at the top and printing line by line, and then scrolling up once you reach the bottom. You’ve probably always taken this for granted when using a terminal app—now you’ll build it! - You’ll write the actual terminal emulator in
user/term.cthat reads the input from the keyboard, and uses your text console library to draw what you typed and the output of the commands to the screen
Why make a separate library? The text console part (e.g., printing text to a canvas, wrapping it at the edge of the screen, and scrolling up) will be useful in any program that draws text to the screen. Therefore, we ask that you write this code as a library (i.e., a struct and a series of helper functions) so you can reuse it. We will provide some example programs that you’ll adapt to use your library–for example, in Quest 4, you’ll modify your BASIC interpreter so you can use it on the screen!
Step D-1: start the library
To get a feel for what this all means, here’s an example for how we’d
suggest starting your terminal emulator (user/term.c)—imagine
that you started by writing this code, which just writes two lines of
text to the screen:
term_t term; // a struct from user/lib/u_vterm.h
// Set up the terminal struct, given info from the framebuffer (height, width, etc.)
term_init(&term, fb_info);
// write some lines of text
term_printf(&term, "hello world!\n");
term_printf(&term, "hello again!\n");To display both lines, each call to term_printf should draw the
characters at different coordinates in the framebuffer. However, when
we write the application we don’t want to think about (x,y) screen
coordinates or pixel values—we just want to draw text. Therefore,
the goal of our terminal library (i.e., the term_ functions, and the
term_t struct) is to abstract these items away from the application,
so you can just focus on deciding what to print.
What is term_t? This is a struct to keep some metadata about
the screen and the state of your terminal. Consider: after drawing
one character, how do you know where to draw the next one? Terminals
usually keep track of a cursor, which defines the next place to
write—this, among other things, might be part of your metadata!
Why pass in the term_t struct to every call? In the next quest,
we’ll run multiple graphical processes at the same time, each drawing
to different parts of the screen. If multiple processes need to draw
text, they’ll have different cursor positions, and other metadata!
Therefore, we need to make the functions operate on different terminal
info, instead of, e.g., using a bunch of global variables.
Here’s how we recommend starting:
Task: In your user/term.c, write some code like the example
above. Then, start filling in your library functions and
your term_t struct to make them work! Some notes about this:
- It’s up to you to decide what your functions looks like, and what
to put in your
term_tstruct. Start with an initial idea, and then update as you continue working on the library - You’ll need a new
sprintffor user programs: theprintffunction we wrote in Project 1 lives in the kernel, and it’s designed specifically to write to the UART. To write strings in our terminal library, we need similar formatting functionality, but we just need to create a string in memory so we can draw it withdraw_string. We can also put this version in the user code, since only user programs will use the terminal library. Here’s how we recommend approaching this:- Create an
sprintffunction inuser/lib/u_string.{c,h}. You can either generate this with an AI tool, or copy your existingprintffrom the kernel and modify it (i.e., strip out the UART and interrupt stuff, and just write to a string). If you generate a new one, make sure that it supports the same format specifiers we required in Project 1. - As you keep working, you might find you want other string library
functions too (e.g.,
strlen, etc.). Feel free to add these touser/lib/u_string.{c,h}, so you have them for other programs too. (As withsprintf, you can either generate them with an AI tool or import well-known implementations.)
- Create an
- Once you have an
sprintf, hook it up to yourterm_printf. Use yoursprintfto fill a temporary buffer, which you can then draw to the display.- To make this easier, we recommend writing a
term_putchelper to write just one character at a time, so you keep all the logic for advancing the cursor in one place. - If the current line is too long for one row of text, the text should wrap to the next line. (How can your metadata help you with this?)
- To make this easier, we recommend writing a
- To move to the next line, you should handle
\n(go to the start of the next line) and\r(go to the start of this line). Now you know how these are implemented in your computer’s terminal emulator! - What colors should your text and background be? Should it be
dark mode, or light mode? You decide! You can even make the
colors configurable in
term_init, so different terminals have different colors! Anything is fine, so long as we can actually read the text 😄 - For now, don’t worry about what happens if you print too many rows, such that you exceed the terminal size. That’s the next part!
Once you’re done, you should see your display printing two lines of text—just like a terminal!
🤖 AI use/coding: allowed, including agentic coding. Here’s a recommended workflow to get the most out of this part:
- Figure out the design yourself (e.g., what your functions should
be, what should go in the
term_tstruct) - Ask AI to fill in the functions, particularly for the
printf-related parts. The exact code isn’t too important here, as long as you understand how the parts fit together.
Why?
This task involves a good amount of code (which AI is helpful with), but also many design decisions (which you should make yourself). This is a great example of a task where AI can be a real benefit in terms of speeding up implementation, but also where AI is dangerous in that it can produce a lot of code that doesn’t quite work or which you don’t understand at all.
You must be able to explain your design choices and how they map to the code in a whiteboard discussion.
For context, see the principles around AI use in CS 1670.
Helpful hints for effective AI use
We recommend being specific about breaking the task down instead of
asking for “a terminal”. A prompt like “write a function that takes
my struct term, a character, and draws it at the cursor, advancing
the cursor and wrapping at the right edge” is a good idea.
If you just tell an AI, “write me a terminal”
will get you something that assumes malloc, strlen, fork, and a
bunch of other functionality we don’t have.
Instead, prompt the AI specifically: you are writing “freestanding”
C (i.e., there is no standard library), there is no malloc, you are
using ARM64, and that the only I/O available is the getc() and
draw_char() functions you already wrote (paste their signatures).
Important: overall, you must understand what the terminal library does conceptually, and why you need it; you should expect you might need to talk about this in whiteboard discussions!
Step D-2: scrolling up
The next fundamental part of a terminal is handling scrolling: when
you print too much text, the terminal prints to the next line, and
everything else scrolls up. To think about implementing this, modify
your user/term.c to continually print some text that changes every
line, using your shiny new term_printf:
// . . .
int count = 0;
while (true) {
term_printf(&term, "count: %d\n", count);
count++;
// Loop that spins for a bit (increase max if it's printing too fast)
// (remove this for the next step)
for (uint64 i = 0; i < 1000000; i++) {
// do nothing
}
}Task: Now, modify your term_putc to scroll up when you would
otherwise exceed the last line of the terminal. Here’s a rough
strategy:
memcpyall the current pixel data in the terminal one row up (except for the first row).- Clear any old data in the last row by filling it with the background color.
- Start drawing at the start of the last row.
When you’re done, you should see your terminal scrolling, like you’d expect from a “real” terminal! It turns out that something as simple as scrolling up by a line requires quite a bit of implementation work when using a pixel-based framebuffer.
🤖 AI use/coding: allowed, including agentic coding.
Why?
The math here isn’t too important, so we encourage you to generate this code. We do recommend reading it, though: you’ve been seeing terminals scroll your entire CS career–this is how it works!
For context, see the principles around AI use in CS 1670.
Step D-3: make the terminal emulator
Now it’s time to make the terminal emulator actually read input and
run commands. In this part, you’ll remove the test code we’ve been
writing in user/term.c and replace it with the logic and commands to
behave like a terminal. Broadly, this has two parts:
- Adding the logic to read in characters from the user, write them to the terminal, and then run the command the user typed when they press Enter.
- Implementing some commands to make your terminal display some useful info about your system!
Task: Use your terminal library to implement a terminal that runs commands. Here’s a sketch, and some requirements (with more hints below):
For the terminal logic:
- You should draw a prompt (e.g.,
>or$) to indicate when the terminal is waiting for input. - As you read characters with
getc(), they should be printed to the terminal as you type them. This behavior is called terminal echo. - You’ll need to keep track of the current line being typed (i.e.,
the accumulated characters before
Enteris pressed) to know what command to run. - Your terminal must allow users to erase characters with backspace.
Note that some keyboards send backspace as ASCII
0x08(BS, called “Backspace”) or0x7F(DEL, called “Delete”). You should handle both. - To implement some of the logic for commands, you’ll likely want
some more string functions (like
strcmp): add these touser/lib/u_string.{c,h}so they’re available for the programs later.
For the commands: you should handle at least the following commands (expand the hints for details):
help— list the commands.clear— clear the screen.echo <text>— print the rest of the line.ps— print the process table: PID, name, and state for every processuptime— how long the machine has been up
To implement ps and uptime, you will need some more
functionality from your kernel. Specifically, you will need:
- A tick counter. Project 2’s timer driver arms
the timer and calls your callback, but nothing ever counts. Add a
global
uint64incremented on every timer interrupt, plus an accessor. You will need it here foruptime, in Quest 4 to schedule screen refreshes. (If you did Quest 2’ssleep_msbonus quest, you may already have something similar.) - One or two more trampoline functions: e.g., to get the current
timer tick count (for
uptime) and some information about running processes (forps). Even if you do the rest of this task with AI (policy below), we recommend that you design the interface for these functions yourself: this is a good design exercise in deciding what information a kernel should expose to user programs!
Finally, stop loading basic and keytest. They talk to the
serial console, and from now on the terminal on the screen is the
program you type into. BASIC will come back in Quest 4.
Hints
- Test the scrolling by holding down Enter. Similarly, you should test the line editing (e.g., terminal echo and backspace) by typing rubbish and backspacing all of it. Also, try backspacing past the start of the line (what should happen?)
- You can think of “uptime” (in seconds) as your timer’s total tick
count, divided by your timer frequency. In Project 2, you chose a
timer quantum: we recommend making a definition for this (e.g.,
TIMER_HZ) to use in your computation. - When implementing
ps, you should not directly return pointers to the kernel’sprocstructs to user programs: these structs are filled with pointers to kernel data structures, which is dangerous from a security perspective, and will be problematic when we add user-kernel separation in Projects 4 and 5. Instead, we recommend defining a small struct with just the informationpsneeds to show, and have your kernel fill it in when you call the trampoline function, which is similar to how modern OSes expose this information.
🤖 AI use/coding: allowed, including agentic coding. You may generate any of the code for the line editing, and terminal commands.
Important note: you must still understand how output gets to the
screen, and be able to walk through this flow. You must also
understand the kernel interfaces you add for ps and uptime, and
be able to explain what happens when your terminal runs these
commands. Don’t let an agent invent a trampoline function for you
without reading what it did; this is your user-kernel interface, so,
in practice, this would be very security-critical code!
Why?
Other than the interface part, everything difficult—getting
characters, drawing them, having a kernel that keeps running while you
type—is something you have already built yourself. What is left is
line editing, and some string functions like strcmp, which are
interesting, but not terribly educational about operation systems.
For context, see the principles around AI use in CS 1670.
Helpful hints for effective AI use
Similar to the first terminal task, don’t just ask an AI “implement line editing” or “implement uptime”: this will produce code that relies on library functions and OS features that aren’t available in our OS. Instead, break down the task and be specific about what functionality is available–see Step D-1 for details.
Part E: something that moves
So far, our output to the screen has been text and static graphics. Let’s see something that moves!
Task: Copy in a bouncing box program, pong.c from our repository of provided programs. Load it, and run it!
pong.c bounces a big white box around the screen, like an old DVD screensaver, at about 60 frames a second. Every frame, it erases the box where it was and draws it where it is now.
Before you run it, read it. It is a short program, and there are two important parts:
- The wiring section at the top. This code sets up the connection between your OS and the program using four small functions—where to draw, what to do when a frame is finished, when to draw the next one, and what time it is. Each is written to work now and to be rewritten in Quest 4.
- Where the time comes from.
pongreads the ARM generic timer’s counter directly, with the sameCNTPCT_EL0andCNTFRQ_EL0registers your timer driver used in Project 2. Ask yourself if there’s a better design to make the program independent of the hardware timer, and what you would need to change in your OS to support it.
Run it. Then watch the box carefully for thirty seconds.
🤖 AI use/coding: allowed, including agentic coding—though there is no code to write here: we provide the program.
For context, see the principles around AI use in CS 1670.
What you should see is the box tearing. This may not be terribly visible on QEMU, but it is very evident when running on hardware—recall our demo in class. Several times a second, the box looks wrong for a moment: cut short at the top or the bottom, or with a step in one of its sides, as if its top half and its bottom half had been drawn in slightly different places. The problem here is that the display reads the framebuffer from top to bottom, over and over, and pong moves the box whenever it likes—so a picture on the screen can easily consist of some rows read before pong moved the box and some rows read after. What you see is neither frame, but a splice of the two!
This is not a bug in your code. It is the natural consequence of the design you have: every process writes straight into the one piece of memory the display is reading from, whenever it feels like it. We’ll fix this in Quest 4. For now, note the symptom—you will want to compare against it later.
Hints
- A
screendumpfrom QEMU’s monitor (Appendix B) freezes one frame so that you can look at a tear properly—though read the warning there about what a single dump does and does not tell you. - Try smaller
STEP_XandSTEP_Yvalues inpong.c. The less the box moves between frames, the smaller the step in a torn frame and the harder it is to see. Tearing does not go away when you stop noticing it. - Does it matter how big the box is, or what else is running? Think about how long the window is during which the framebuffer holds neither the old picture nor the new one.
Check your work
Run make qemu-fb (or make qemu-ws) with idle, and one of logo, term, and pong loaded. Depending on which one you run, you should see:
- your logo and your OS’s name, drawn by
logo; - a box bouncing (and tearing), drawn by
pong; - a prompt that responds to your typing, drawn by
term; - and, on the serial console, everything your kernel prints, as before.
Now load all of idle, logo, term, and pong at once, and run them. You should see a bouncing box, a prompt, and your logo…
…all overlapping and drawing on top of each other, because nobody has told them where they may draw: the box erases whatever it passes over, and whichever program cleared the screen last wins (you may not see your logo at all). That is the subject of Quest 4. Before you go on, take a screenshot of the mess; it makes the comparison at the end of Quest 4 much more satisfying.
We also recommend that you check the following:
- Your OS copes without a display. Note that you cannot test this with
make qemu: QEMU emulates the GPU whether or not it shows you a window, so you get a framebuffer either way—you just cannot see it. Test it by pretending instead: makefb_init()give up straight away, after settingpixelstoNULL. Your programs should notice and say so, rather than writing to address zero. - The pitch is respected. Draw a single-pixel-wide vertical line from the top of the screen to the bottom, at x = 0. If it is straight, your row stride is right. If it walks diagonally, it isn’t. (Appendix B has a table of the other things that can go wrong—a black screen, mirrored text, swapped colors—and what they mean.)
psin your terminal agrees withgdb. Runps, then break in the kernel and print the process table in GDB. The same PIDs should have the same states and metadata.
Bonus Quests (Extra Credit)
Important note: All extra credit work requires you to meet with an instructor at office hours to discuss your work in order to get credit. There will be no exceptions to this, since extra credit is strictly optional.
Quest 3-E1: A better font (variable difficulty)
Task (extra credit): Do something interesting with fonts. Some options, pick one:
- Design a complete alternative font (easy)—at least the printable ASCII range—in a style of your choosing, and add a way to switch between it and the built-in one at runtime. (Think about how you implemented focus switching: a similar special key combination is useful here.)
- Implement a proportional font (moderate): add a per-character width table so that
itakes less space thanm, and makedraw_stringadvance by the right amount. Then note what this breaks (hint: your terminal’s cursor arithmetic, and anything that assumed a character grid face problems now). - Implement a real vector glyph (hard): pick one character, describe its outline as a handful of line segments, and rasterize it at an arbitrary size. Compare the result at 8 pixels tall and at 80 pixels tall with the bitmap version scaled up.
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 3-E2: Custom crash screen ("Blue Screen of Death") (easy–moderate)
Most kernels have some kind of error screen they show show when something goes wrong so badly that the kernel cannot recover (e.g., a synchronous exception that can’t be handled). Microsoft Windows has the “blue screen of death” (BSOD) which has become synonymous with a kernel crash, while classic Mac OS had the “Bomb screen” and the hardware could produce a “Sad Mac” icon. (Linux has a more utilitarian approach, printing a stack trace and a message to the console.)
Your can now implement your crash screen and get creative with it!
Task (extra credit): Change the handler for a synchronous exception to draw a crash screen to the framebuffer before looping forever. Your crash screen should make clear to the user that an unrecoverable error has occurred. You might also want to include some information about the error, although you could also keep this information on the serial/UART console (which is what many OSes do).
If you really want to get fancy, include the possibility for the user to press a key to reboot the machine. (Check out this forum thread for some ideas on how to reset the Raspberry Pi from software. We do not know if this works in QEMU, however, so you may want to test on real hardware.)
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 3-E3: Tell the GPU something else (moderate)
The mailbox property interface has dozens of tags, and you have used only a few of them.
Task (extra credit): Use the mailbox to find out three things about the machine you are running on, and print them at boot: the board’s serial number, the amount of ARM memory and the amount of GPU memory (they split the physical RAM between them—think about how that might work!), and the CPU’s current clock rate. Compare what you get in QEMU with what you get on a real Raspberry Pi. Some of these are wrong or absent under emulation!
For a harder version: ask the GPU to change the ARM clock rate, and measure the effect on how long primecheck takes.
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 4: Many processes, one screen
Files: kernel/memlayout.h, kernel/limits.h, kernel/elf.c, kernel/proc.h, kernel/proc.c, kernel/init.c, kernel/compositor.c, kernel/compositor.h, kernel/drivers/fb.c, kernel/drivers/fb.h, kernel/drivers/timer.c, kernel/drivers/timer.h, kernel/interrupts.c, kernel/klib.c, kernel/kernel.c, user/lib/u_common.c, user/lib/u_common.h, user/lib/u_fb.c, user/lib/u_fb.h, user/term.c, user/logo.c, user/basic.c, user/pong.c, user/clock.c (provided).
Overview
At the end of Quest 3 you had several programs drawing on one screen, all over each other, and a box that tears whenever it moves. Let’s fix this!
In this quest you will:
- Give each process a private canvas inside its own memory, and decide—in the kernel—where on the screen each canvas appears.
- Move every program onto its canvas, including BASIC, which gets a window of its own.
- Write a compositor: kernel code that, sixty times a second, copies the canvases onto the screen. It will draw into a hidden half of the framebuffer and then flip it into view, so the display never shows a half-drawn picture.
- Let programs wait for the next frame with
wait_for_vsync(), and make sure a frame that a program is halfway through drawing never reaches the screen. - Deliberately break the illusion of simultaneity, and then put it back.
At the end, you will have four programs visible and running at once, none of them able to draw over another, nothing tearing, and the keyboard going to whichever one you pick. This is a simple version of the desktop computing environment you have on your laptop.
Why are we doing this?
This quest is where the two halves of this project meet the theme of the whole course. Your kernel already mediates the CPU, which it divides in time, and in Quest 2 it learned to hand the keyboard to one owner at a time. The screen forces a third pattern: it is divided in space.
Those three patterns—time-slice it, partition it, hand it to one owner at a time—cover most of what an operating system does with hardware. It is also, almost exactly, how the screen you are reading this on works. No program on your laptop draws on the screen. Each one draws into a buffer of its own, and a compositor—Quartz on macOS, the Desktop Window Manager on Windows, and, e.g., the Wayland compositor on Linux—decides where each buffer goes and combines them into what you see.
Background: partitioning a display
We will use the simplest scheme that works: divide the screen into a fixed grid of equal cells, and give each drawing process one of them. A process gets a canvas—a block of pixels the size of its window—in its own memory, and draws on that. It never sees the screen at all. The kernel decides where on the screen each canvas goes, and copies it there:
0 320 640
0 ┌──────────────────────┬──────────────────────┐
│ ┌──────────────────┐ │ ┌──────────────────┐ │
│ │ PID 1's canvas │ │ │ PID 2's canvas │ │
│ │ (316 x 236) │ │ │ (316 x 236) │ │
│ └──────────────────┘ │ └──────────────────┘ │
240 ├──────────────────────┼──────────────────────┤
│ ┌──────────────────┐ │ ┌──────────────────┐ │
│ │ PID 3's canvas │ │ │ PID 4's canvas │ │
│ │ │ │ │ │ │
│ └──────────────────┘ │ └──────────────────┘ │
480 └──────────────────────┴──────────────────────┘
the 2-pixel gap around each canvas is a border,
drawn by the kernelNotice how much stronger this is than the obvious alternative, in which every process draws directly on the screen and is told which rectangle it may use. There, staying inside your rectangle is a promise each program makes; a bug in one program’s arithmetic scribbles on its neighbor. Here, a process has no pointer to the screen or to anybody else’s canvas, so it cannot draw outside its window by accident—and it doesn’t even need to know where its window is. The decision belongs entirely to the kernel.
(Staying inside the window is still not enforced. There is no virtual memory or protection, so a process that computes some other address and writes to it will succeed. This also was true in early graphical OSes; we’ll fix it in Project 5.)
What do real window systems do?
The scheme you are building is a tiling window manager with a fixed layout, which is a real and perfectly respectable design—dwm, i3, and the tiling modes of macOS and Windows all work this way. Many real OSes and window managers add other features:
- Overlapping windows. Since every program already draws into a buffer of its own, overlapping windows are “only” a matter of copying them in the right order, back to front. Your compositor will be most of the way there already and you can add movable windows as an extra credit quest.
- GPU composition. Most modern compositors do not copy pixels with the CPU at all: they hand the GPU a list of buffers and positions, and the GPU combines them on the fly. (The CPU-based way still exists as a fallback today in case there is no GPU driver available.)
- Damage tracking. Real systems keep track of which rectangle of a window changed, and copy only that. CS 1690/2670 students build a coarse version in Quest 5, and a finer one is extra credit there.
Background: why it tears
A display is not a passive thing that changes when you write to memory. The display controller scans out the framebuffer continuously: it walks the buffer from the first pixel to the last, emitting each one to the screen, about 60 times a second. When it reaches the bottom it goes back to the top, and the brief pause in between is called the vertical blanking interval, or vblank.
Now consider what pong did in Quest 3. It wrote directly into that same buffer, at arbitrary moments, with no coordination at all. So, consider what happens when the GPU/display controller is scanning the framebuffer at a certain row (the “scanout position” column), and pong decides to move the box (the “what pong is doing” column):
scanout position what pong is doing
───────────────────────────────────────────────────────
row 0 .. 200 ────► (nothing yet)
row 201 ────► pong erases the box at its old position
row 202 .. 300 ────► pong draws the box at its new position
row 301 .. 479 ────► (done)The rows above 201 were read before the change and show the old frame. The rows below were read after the change and show the new one. This is classic race condition, in this case between hardware and software! What you see on the screen for that sixtieth of a second is neither frame but a splice of the two, with a visible seam where they meet. This is called “tearing”.
Erasing makes it worse. pong erases the old box and then draws the new one, so there is a moment—possibly a long one, if it gets preempted in between—when the box is absent from the buffer entirely. If the scanout catches that moment, the box vanishes for a frame.
Giving every program a canvas does not, on its own, fix any of this. It just moves the problem: now it is the compositor writing into the buffer the display is reading.
Background: double-buffering and page flipping
The fix is to never let the display read a buffer that is being modified. The standard technique for this is called double buffering and has been widely implemented in 2D graphics since the 1980s. It wastes a bit of memory in exchange for better graphics. The key trick is to keep two framebuffers:
- the front buffer, which the display is scanning out;
- the back buffer, which the kernel draws into.
When the back buffer draw is complete, the kernel swaps the buffers’ roles: it tells the display controller/GPU to read (“scan out”) the other buffer, which used to be the back buffer, and draw into what used to be the front buffer. The display always reads a finished frame, so there is no seam. (If the swap is deferred to the vblank interval there is not even a moment when the change is visible.)
The Raspberry Pi’s GPU makes this almost free, using the physical/virtual framebuffer size distinction you met in Quest 3. Ask for a virtual buffer that is twice as tall as the display and you get one contiguous allocation holding two full frames:
GPU framebuffer (virtual: 640 x 960)
┌──────────────────────┐ ─┐
│ │ │ half 0 (rows 0..479)
│ │ │
├──────────────────────┤ ─┤ ◄── virtual y offset selects
│ │ │ which half is displayed
│ │ │ half 1 (rows 480..959)
└──────────────────────┘ ─┘The top half of this larger framebuffer (640 x 960, which is two times 640 x 480 stack on top of each other) is the initial front buffer, and the bottom half is the initial back buffer. The “flip” between them becomes a single mailbox message to the Raspberry Pi GPU: you set the virtual y-offset to 0 (the top half is the front buffer) or 480 (the bottom half is the front buffer).
Background: two races, not one
There are two different things that can go wrong here, and it is important to understand both, as each has its own fix.
process ──draws──► [ its canvas ] ──compositor copies──► [ GPU buffer ] ──display reads──► screen
(in its own memory) (hidden half)
race 2: the compositor copies race 1: the display reads
a canvas the process is halfway a buffer the compositor is
through drawing halfway through writingRace 1 is between the display and the kernel compositor, and double-buffering fixes it: the compositor only ever writes to the half that is not on the screen.
Race 2 is between the kernel compositor and a process. A process draws on its canvas whenever it is scheduled; the compositor copies the canvas whenever the timer says so. If the timer fires while pong has erased its box but not yet redrawn it (e.g., because it got preempted by the timer), the compositor faithfully copies a frame with no box in it. The fix for this is for the program to say when it’s done drawing, and for the compositor to keep showing a program’s last completed frame until the next one has finished drawing. We’ll achieve this with an API called wait_for_vsync().
What you need to do
Part A: a window and a canvas for every process
A canvas is big: at 316 x 236 pixels and 4 bytes per pixel it is about 300 KiB, which is nearly five times the 64 KiB of memory we recommended for each process in Project 2. It also belongs to the process, so we will put it in the process’s memory. That means we must make room first.
Task: Give every process that draws a window on the screen and a canvas in its own memory.
- Make room. Grow
PROC_SIZEso that each process’s memory slot holds its program, its stack, and a canvas—1 MiB per process is probably sufficient. MovePROC_STARTsomewhere with room forNPROCof the bigger slots that collides with nothing else (16 of them may not fit below the next piece of data in your memory layout—check the diagram!). Decide where in its slot a process’s canvas lives: the program is at the bottom and the stack is at the top, so the canvas should go somewhere in between, with a comfortable gap on either side. Write that down as a constant too. - Define
struct canvas, in a header the kernel can see and inuser/lib/u_fb.h, kept in sync by hand as you did forstruct fb_info. This is the struct your drawing library already draws on: a pixel pointer, a width, a height, and the row stride in pixels. If you are in CS 1690/2670, also add one more field, avolatile uint32 changed, for Quest 5, where the compositor learns to skip windows that have not changed. - Add a window and a canvas to
struct proc: this should specify a rectangle saying where on the screen the window is (only the kernel needs this), include a pointer to the process’sstruct canvas, and the kernel’s own pointer to where the canvas is in the process’s memory. Initialize them inallocproc(). - Lay out the windows in
init(). Divide the screen into a 2x2 grid, and give each drawing process a cell, minus a border of 2 pixels on every side for the kernel to draw in Part C. Note that you’ll need to add some indication to determine whether a process gets a window: the idle process should get no window, for example. - Tell the process. Add a trampoline slot and kernel function,
get_canvas(), that returns a pointer to the calling process’sstruct canvas(orNULLif it has none), and a user-side wrapper. - Print the windows at boot, next to your process table, so that you can see who got what.
Hints
load_elf()zeroes a process’s whole slot, so set up the canvas after loading the program, not before.CHECK()that the pixels end below the start of the stack. A canvas that overlaps a stack produces the weirdest bugs!- If you defined
PROC_SIZEin two places in Project 2, change both or keep just one. - Compute the grid from the framebuffer’s size rather than from magic numbers:
fb->width / 2andfb->height / 2should still work if you change the resolution. - The
struct canvasin a process’s memory is shared memory, in the plainest possible sense: the process writes it and the kernel reads it. So the kernel must not trust it—a process could overwrite the pixel pointer with any address it liked, and a compositor that believed it would copy whatever that address held onto the screen. Keep the kernel’s own record of where each canvas is, instruct proc, and use only that. - Beware of a 16-byte struct. A rectangle of four
uint32s a struct the compiler likes to copy with a singleldp/stpinstruction pair, and those require 8-byte alignment—which a struct ofuint32s does not otherwise have. The CPU does not tolerate unaligned accesses (especially on hardware): you’ll get an alignment fault (exception class0x25inESR_EL1) in code that contains nomemcpyat all. Give the struct__attribute__((aligned(8)))to force 8-byte alignment, and ideally use a_Static_assertto check the alignment. - Decide what to do if there are more drawing processes than grid cells. One possible answer is that the extra processes just don’t get a window!
🤖 AI use/coding: auxiliary use allowed. You may use AI to check your grid arithmetic and to help with the trampoline plumbing. You may not hand it “make the kernel assign windows and canvases to processes”.
Why?
This is short code expressing the central idea of the quest: that the partitioning of a shared device is a decision the operating system makes and records, not an arrangement the programs come to among themselves. You should be able to say exactly where that decision is made in your code, and where in memory every canvas is.
For context, see the principles around AI use in CS 1670.
Task: Update your memory layout diagram with the new process area, and with where a canvas sits inside a process’s slot.
🤖 AI use/coding: NOT allowed. Why? As in Projects 1 and 2, we want you to maintain your own mental image of your OS’s memory layout—and this quest moves the biggest thing in it.
Part B: programs draw on their canvases
Task: Move every program onto its own canvas.
- Your terminal. Have
term.cget its canvas fromget_canvas()instead of making one that covers the screen. If your text console draws on a canvas, as Quest 3 suggested, this is a two-line change. - BASIC. Read its wiring section. Change
basic_start()to get BASIC’s canvas and set up a text console on it, andbasic_putc()to write through that console instead ofprintf(). Input needs no change at all—your kernel already delivers keystrokes to whichever process has the focus. - The clock. Copy in the clock program,
clock.c, from our repository, and rewireclock_get_canvas()to describe the canvas fromget_canvas(). Do the same forpong_get_canvas()inpong.c. Leave the other wiring functions alone for now. - Stop publishing the framebuffer directly. Nothing in user space needs
struct fb_infoany more, so stop writing it atFB_INFO_BASE: keep the kernel’s description of the screen in a variable only the kernel can see, and deleteFB_INFO_BASE,struct fb_infoand your whole-screen canvas helper fromuser/lib/u_fb.h. Anything still using them will no longer compile! In particularlogo.c, whichmakebuilds whether or not you load it, will break—point it atget_canvas()too. From now on, no program has a pointer to anything but its own canvas. - Load
idle,term,basic,clockandpong, in that order, and runmake qemu-fb.
You will see nothing new on the screen: in fact, the screen might now be blank or contain what you had before! Before you go on, make sure you can explain why.
Hints
- A program that gets
NULLfromget_canvas()—because there is no display, or because the kernel gave it no window—should say so on the serial console and get out of the way, as your Quest 3 programs did when there was no framebuffer. - Your logo program has no cell in this layout. Hence, a ported
logo.cthat you load anyway will simply report that it has nowhere to draw. Deciding which processes get a window is itself part of the layout decision your kernel makes. You are welcome to make room for a fifth program by rearranging windows (or, consider the extra credit status bar as one place a “minimized” window could go!). - Since your library clips everything to the canvas, try drawing at
x = 10000and check that nothing happens. Then note that the clipping is voluntary—and ask yourself what a malicious program could still do.
🤖 AI use/coding: allowed, including agentic coding.
Why?
Pointing programs at a different canvas is mechanical and there’s little learning in doing it yourself. The idea behind it is Part A’s, and you have already done that part manually before.
For context, see the principles around AI use in CS 1670.
Part C: a compositor
Your kernel now has to copy every canvas onto the screen, regularly, and the obvious place to do that is the timer interrupt. But your timer still runs at Project 2’s rate of a few interrupts a second, and to drive a display at 60 frames a second the kernel has to get control at least that often—and still have ticks left over for scheduling.
Task: Write a compositor, and make it tear-free.
- A faster clock. Raise
TIMER_HZto a few hundred hertz—600 Hz is a good choice, because it divides evenly by 60—and add two more constants:REFRESH_TICKS, how many ticks pass between screen refreshes (10, for 60 Hz), andQUANTUM_TICKS, how often the scheduler preempts, in ticks (6, say, for a 10 ms quantum). The timer now does two jobs at two rates: you should preempt only on everyQUANTUM_TICKS-th tick; on the others, resume whichever process was running. Then fix anything that converts between ticks and time, such as your terminal’suptime. - Copy. Write a
compositor_tick()function inkernel/compositor.c, and call it from your timer interrupt handler on every tick. EveryREFRESH_TICKSticks, it copies each window’s canvas onto the screen at the window’s position. - Run it. Your windows appear. Watch
pongfor a while: the box tears, a little. That is race 1—the display reading the framebuffer while your compositor writes it! - Double-buffer. In
fb_init(), ask for a virtual bufferFB_HEIGHT * 2rows tall. Clear both halves at boot. Then change the compositor to copy into the half that is not on the screen, send aSET_VIRT_OFFSmailbox message to make that half the visible one, and remember that the other half is now the one to draw into. - Borders. After copying the windows, draw a 2-pixel border around each one, into the same hidden half. Draw the border of the window that has the keyboard focus in a different, bright color, so you can see where your typing will go. Since the compositor copies every window on every refresh, it is simplest to draw the borders on every refresh too.
Hints
- Re-arm your timer against the previous deadline, not against now. Project 2 armed the timer by writing a countdown to
CNTP_TVAL_EL0. That counts from the moment you write it, so every bit of lateness in delivering an interrupt—and under QEMU, interrupts are often more than a millisecond late—becomes a permanently longer period: your might ask for 600 Hz and you get about 360. Instead, keep the time of the next deadline yourself, add one period to it on each interrupt, and write it to the compare register,CNTP_CVAL_EL0. Then lateness on one tick is made up on the next. (If you fall so far behind that the next deadline has already passed, skip ahead rather than firing a burst of interrupts to catch up.) - Be careful with integer division when converting ticks to milliseconds. At 600 Hz,
1000 / 600is 1, which loses you 40% of your precision. The classic solution in kernel programming to this is to multiply both values by a large number (say 1,000) first, then divide. - The two row strides differ. A canvas’s rows are
widthpixels apart; the screen’s arepitchbytes apart, andpitchneed not bewidth * 4. So the copy is onememcpyper row, not one for the whole window. - The
memcpywe gave you copies a byte at a time. That is fast enough under QEMU. On real hardware it is not, and making it faster is part of Quest 5. - Track which framebuffer half is the hidden one in a single variable that your code toggles. Getting this the wrong way around draws into the half on the screen, which looks exactly like the tearing you are trying to fix—so if nothing improves, check this first. Order matters here: copy into the hidden half, then flip to it, then toggle.
- The flip is a mailbox message, so it can be sent with the
mbox_call()you already have. It needs its own 16-byte-aligned buffer: do not reuse the onefb_init()used, because you would then be scribbling on it from an interrupt handler. - The kernel draws the borders, not the programs, because a program cannot be trusted to advertise itself honestly. Note that each window’s border is outside the window, so drawing a border never disturbs a canvas you have copied, and copying a canvas never disturbs a border.
You are doing something we told you never to do. Observe that the compository is copying hundreds of kilobytes, and a mailbox round-trip, in an interrupt handler, with interrupts masked! At 60 Hz that is a substantial fraction of every second during which your machine cannot respond to anything. This isn’t respecting the rule that interrupt handlers should be short and fast.
Do it anyway—it is simple and it is what our reference implementation does. But know that it is wrong, and think about what the right way of doing it would be! (The usual one: the handler should only note that a refresh is due, and wake a dedicated kernel process that does the copying with interrupts enabled. You have every piece needed to build that already—block, wakeup, and a process table—so you could do it, but it would result in more code.)
🤖 AI use/coding: auxiliary use allowed. You may use AI to help with the timer arithmetic, the mailbox flip message, and the row-by-row copy, and to debug. You may not ask it to implement double-buffering for you.
Why?
The idea that the fix for the race condition is to give the reader and the writer separate buffers and swap is a key lesson here, and we want you to understand why double buffering is effective. It only requires about forty lines of code to implement.
For context, see the principles around AI use in CS 1670.
Part D: frame boundaries
Your screen no longer tears, but look closely at pong, especially while something else is busy: every so often the box is cut short, or missing entirely, for a frame. That is race 2. The timer fired while pong had erased its box and not yet redrawn it (or was in the process of redrawing it). The compositor then copies an unfinished frame from the process’s canvas, and the display shows it for a short period of time.
The fix is to detect whether a process is still drawing, and to never copy a canvas while it is actively being modified. There is also a second problem we’ll fix along the way: pong and the clock pace themselves by spinning on yield() until enough time has passed, which costs CPU and is unrelated to when the screen is actually redrawn by the compositor.
Task: Add wait_for_vsync() kernel function exposed to user processes, and use it so that a half-drawn frame never reaches the screen.
- Add a kernel function that blocks the calling process on a channel belonging to the compositor, and expose it through a new trampoline slot. In the compositor, after each refresh,
wakeup()that channel. - Change
clock_frame_wait()andpong_frame_wait()to callwait_for_vsync()instead of spinning. - Hold back windows that are mid-frame. A process that calls
wait_for_vsync()has told you when it’s done drawing: it is between frames exactly when it is blocked. So, when a refresh comes around and a window belongs to a process that is not blocked, that process is halfway through a frame. Its canvas may hold a partial frame, so we should not copy it!
Do not simply skip the window, though. The hidden half’s copy of it is two frames old—it was last brought up to date two refreshes ago—so the flip could bring back an older picture. You’ll want to copy that window from the visible half of the framebuffer instead, which holds the last complete frame.
Now watch! Every animating program runs at the same rate as the screen, regardless of how many of them there are and how much CPU each gets, consumes no CPU at all between frames, and never shows half a frame.
Hints
wait_for_vsync()isblock()andwakeup()from Quest 2, applied to a different event. If it takes you more than ten lines, you are overcomplicating it.- Your kernel needs to know which processes call
wait_for_vsync(): a flag instruct proc, set the first time a process calls the function, achieves this. - Why not have the kernel wait for the program instead? The obvious alternative is to postpone the whole refresh until the program has finished its frame. The code for this is much more complicated, but it also runs into performance problems. Under QEMU, it will seem to work because drawing a frame takes a fraction of a millisecond. On a real Raspberry Pi, with the caches off, drawing one frame can take longer than a refresh interval—and then every other window freezes while one program finishes. Delaying the redraw for only one window, by contrast, does not affect other processes.
- A program that never finishes a frame never gets its window updated. That is the right outcome: it only hurts itself.
- The rule applies only to programs that call
wait_for_vsync. Your terminal and BASIC never call this function—and a BASIC program printing in an endless loop never blocks at all. If the compositor held it back, its window would never be redrawn. The reason here is pragmatic: tearing in a a text program is usually not a big deal, since being caught halfway through drawing a line just briefly shows a partial line that the next refresh replaces. - Copying from the visible half means reading memory that the GPU is scanning out. That is fine: the GPI only reads it, and so do you, so there’s no race condition.
- The name of
wait_for_vsyncis slightly aspirational: you are waking processes after your timer-driven refresh, not after a real vblank interrupt from the display hardware. In a more sophisticated implementation, the GPU would defer the page flip to the next vblank on your behalf, with the same outcome. (You can implement this on the Raspberry Pi, but it is a lot more work and involves dealing with poorly-documented GPU features.) - If
fb_init()found no display, there will never be a refresh, sowait_for_vsync()must return immediately rather than block for ever. - Think about
wakeup’s all-at-once semantics here: every graphical process wakes on the same tick and they all immediately start drawing. Could that be a problem? (For you, no. For a system with a hundred graphical processes, it becomes the thundering herd from Quest 2. How could you fix this? You don’t need to implement the fix, though.)
🤖 AI coding: NOT allowed.
Why?
The changes required here are only a few lines (albeit in different places). They are the lines that demonstrate you understood two things: Quest 2’s blocking mechanism works, unchanged, for a completely different event, and a frame is only ready to draw when the process confirms that it is..
For context, see the principles around AI use in CS 1670.
Part E: break the illusion
Everything is now running. Four programs are animating and responding at once, and it looks like four things happening simultaneously on one machine. But in reality, they aren’t happening truly simultaneously—it’s just that humans can’t perceive the switches between them.
You can prove it to yourself in two minutes!
Task: Vary your scheduler’s quantum, and watch what happens.
- Set
QUANTUM_TICKSso that the quantum is very long—one second, say—and run your OS. (Do not changeTIMER_HZorREFRESH_TICKS.) - Afterwards, make sure to put
QUANTUM_TICKSback to something sensible.
With a one-second quantum, the illusion collapses: whenever BASIC has the CPU, pong freezes, and so does anything you type into the terminal, for up to a second at a time. And yet the screen keeps being redrawn—the borders follow your Shift+Tab immediately, and the frozen windows are perfectly intact. The machine is visibly doing one thing at a time, in turn.
Write down, for your own notes or your whiteboard discussion, what you observed and why. In particular: why does the screen keep refreshing when the programs freeze? What determines the quantum at which the illusion becomes convincing? (Hint: it is a fact about human perception, not about your OS. Look up the frame rate at which motion appears continuous, and the latency at which a keystroke feels instantaneous—roughly 100 ms.)
🤖 AI use/coding: NOT allowed.
Why?
There is barely any code here. This is an observation the whole project has been building toward, and we want you to experience it yourself.
For context, see the principles around AI use in CS 1670.
Check your work
make qemu-fb with idle, term, basic, clock and pong should give you something like this on the serial console:
Hello world from MyOS!
Process table:
pid 0 idle (no window)
pid 1 term window 2,2 316x236 canvas at 0x0000000002180040
pid 2 basic window 322,2 316x236 canvas at 0x0000000002280040
pid 3 clock window 2,242 316x236 canvas at 0x0000000002380040
pid 4 pong window 322,242 316x236 canvas at 0x0000000002480040
[console: keyboard input now goes to pid 1 (term)](your addresses will differ) and, on the display, four programs in four bordered windows, with the focused one highlighted.
Things you should check for:
- No tearing, and no half-drawn frames. The box in
pongshould be whole, always, at every position, and the clock’s hands should never flicker. Look hard, for a minute, and then again while a BASIC loop is running. A seam that appears every few seconds means one of your two races is still open; check, in this order, which half you copy into, and whether you hold back windows whose programs are mid-frame. (Under QEMU, a very occasional torn frame—one every half a minute or so—can be the emulator’s fault rather than yours: QEMU’s display is not synchronized with your flips.) - Focus switching moves the highlight. Press Shift+Tab and watch the border move, and the announcement appear on the serial console. Type into each program in turn and confirm that the characters go where you expect.
- BASIC runs in its window, and a BASIC program printing in an endless loop scrolls its window without freezing anybody else’s. Ctrl-C still stops it.
- Nobody draws outside their window. Watch the borders for a minute. A single stray pixel in a border or a neighbor’s window means somebody has a pointer they should not have.
- Your OS still copes without a display. Pretend again, as in Quest 3, by making
fb_init()give up: every drawing program should say it has nowhere to draw, and nothing should block for ever.
Task: Save a screenshot of your OS running four programs in four windows, as docs/desktop.png (or .jpg) in your projects repository, and push it. This is part of your project submission.
Congratulations—you have built a machine that several programs share, in space and in time, and that you can talk to, and whose screen never shows a frame that is not finished. If you are in CS 1670, that completes the project, modulo hand-in. If you are in CS 1690/2670, there is one quest to go: making all of this fast on the real hardware.
Bonus Quests (Extra Credit)
Important note: All extra credit work requires you to meet with an instructor at office hours to discuss your work in order to get credit. There will be no exceptions to this, since extra credit is strictly optional.
Quest 4-E1: A status bar (easy)
Real operating systems keep a strip of screen for themselves, and you can do the same to put OS-provided content on the screen.
Task (extra credit): Reserve a band at the top of the screen—ten or twenty pixels—for the kernel to draw in, and shrink the window grid to fit below it. Draw, in the kernel: your OS’s name, which process has the keyboard focus, and one live statistic of your choosing (uptime, number of runnable processes, context switches per second, dropped input bytes).
Your compositor is the natural place to draw it, since it already draws borders into the hidden half of the framebuffer. Think about how often it needs redrawing. (If you redraw it only when something on it has changed, remember that a change has to reach both halves of the framebuffer.)
Note that this makes your kernel need its own copy of the drawing and font code, since the versions you wrote are in the user library. Think about where that code should live so you are not maintaining two copies of draw_char.
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 4-E2: Title bars (easy–moderate)
Task (extra credit): Give every window a title bar, drawn by the kernel, showing the process’s name and PID, and highlight the title bar of the window that has the focus.
One way to do this is to design box-drawing characters (corners, horizontal, vertical, tees) as part of Quest 3’s custom-glyph task and build the window frames out of those.
Then think about the consequence: the title bar occupies pixels, so each process’s canvas must shrink. Where does that adjustment belong, and does any program have to know?
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 4-E3: Overlapping, movable windows (quite difficult)
Your windows tile because you laid them out that way, not because they have to: every program already draws into a canvas of its own, and where that canvas goes on the screen is entirely the compositor’s business.
Task (extra credit): Let windows overlap. Keep a stacking order, and have the compositor draw the windows back to front, so that the front one wins where they overlap. Then add keys—handled by the kernel, like Shift+Tab—to raise the focused window to the front and to move it around the screen using custom key combinations (e.g., Shift-Alt-Left moves the window left by 20 pixels).
Things get harder as you progress with this quest, so think about it before you start. Who draws the borders, and in what order? What does holding back a mid-frame window mean when part of it is covered? And if you have done Quest 5’s damage detection: when a window moves or is raised, which pixels of which other windows need copying?
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 5: Faster graphics (CS 1690/2670)
Files: kernel/utils.c, kernel/compositor.c, kernel/compositor.h, kernel/console.c, kernel/proc.h, kernel/proc.c, kernel/init.c, kernel/drivers/timer.c, kernel/drivers/timer.h, user/lib/u_string.c, user/lib/u_fb.c, user/lib/u_term.c, user/pong.c, user/clock.c.
This quest is required for CS 1690/2670 students only. CS 1670 students are welcome to do it, but it is not graded for you. (Under QEMU your desktop is fast already; this quest is about the real Raspberry Pi.)
Overview
At the end of Quest 4 your desktop is correct: four windows, nothing tearing, nothing half-drawn. Under QEMU it is also quick. On a real Raspberry Pi, it is not quick at all. In this quest, you will make it fast enough to be pleasant on the hardware. You’ll apply techniques that cover the two ways that almost every performance problem is solved through: by doing the work you cannot avoid more cheaply, and by not doing the work you do not need to do.
You will:
- Run your Quest 4 desktop on the Pi, and look at where the time goes.
- Make
memcpycopy data faster, which speeds up the work you cannot avoid. - Make the compositor copy only the windows whose canvases have changed, which avoids unnecessary work.
In addition, we give you the freedom to go further and improve performance even more!
Why are we doing this?
Having a desktop that is laggy is no fun. In addition, the two techniques in this quest are the ones you will reach for again and again! Damage detection in particular is one instance of a very general idea—remember what is already up to date, and redo only what changed—and your file system’s cache, your build system, and every incremental algorithm you will ever write work the same way.
It is also the first time in this course that you will make something faster rather than making it work, and the discipline is different: before you change anything, have a theory of where the time is going, and afterwards, a way to tell whether you were right.
Background: why it is slow on the hardware
Your kernel runs with the CPU’s caches turned off: you did that in Project 2, when you set SCTLR_EL1. A modern CPU without its caches is very slow indeed. Every instruction it executes has to be fetched from main memory, one at a time, and so does every byte it reads or writes. QEMU, meanwhile, runs your code on your laptop’s CPU, caches and all, which is why you never noticed.
Now count the work your compositor does. Every refresh, it copies all four windows: about 300 KB each, so 1.2 MB, sixty times a second. And it copies them through a memcpy that moves one byte per trip round its loop, at several instructions per trip—every one of them fetched from memory. Add the time each program spends drawing its own frames, with the caches just as off, and it is no surprise that your windows update slowly. (It is also why Quest 4’s holding back of mid-frame windows matters so much on the Pi: there, programs are often still drawing when a refresh comes around.)
There are two easy solutions:
- Make each copy cheaper, by moving eight bytes per instruction instead of one.
- Copy less: most of what the compositor copies has not changed since it last copied it.
Background: not copying what has not changed
Look at your desktop: a terminal and BASIC that change only when you type, a clock that changes once a second, and pong, which changes every frame. A compositor that copies every canvas sixty times a second is copying three windows’ worth of pixels that have not changed, almost all of the time.
The fix is damage detection: work out what changed, and copy only that. Real systems track it down to small rectangles. You will do the coarsest version possible: each canvas has a changed flag—the field you added to struct canvas in Quest 4—which your drawing library sets whenever it draws, and the compositor clears whenever it copies. A window whose flag is clear is not copied at all.
There is a catch, and it comes from double-buffering. When a window changes, the compositor copies it into the hidden half and flips. Now the other half is hidden—and it still has the window as it was before the change. If the window does not change again, and the compositor copies nothing, the next flip puts the old picture back on the screen. A change has to be copied into both halves, one refresh after the other.
What you need to do
Part A: look at the problem
Task: Run your Quest 4 desktop on your Raspberry Pi hardware. To see the display output, connect your Pi to any external monitor or projector using the Pi’s HDMI port. To do this, we recommend one of the following options:
- You can find a free classroom somewhere in the CIT, or another room on campus, and connect to the projector.
- Come to any office hours (not just those marked “[HW]”) and ask the TA if you can use the projector. All of our office hours rooms have either a projector or a monitor
- If you have a TV or monitor that supports HDMI, you can use this too. Any device that uses HDMI should work
- If you can’t access a monitor or TV, you can also test your work on the grading server using the “Run on hardware (with framebuffer)” button. This button runs your code on the Remote Pi and captures the HDMI video signal using a special capture device. The video quality isn’t perfect (see the note on limitation when you upload), but you should get a decent enough view to understand the performance
Watch pong and the clock, and type into the terminal. How smoothly
does the box move, compared with QEMU? How quickly do your keystrokes
appear? Then work out, on paper, how many bytes your compositor copies
per refresh, and how many of those bytes have actually changed. Write
down the numbers and what you see, as you will compare against it at
the end of this quest.
Hints
- A
DEBUG()line in your compositor that prints, once a second, how many refreshes it managed and how many times it copied each window tells you most of what you need to know, and costs nothing when you build withoutDEBUG_LVL. Decide when a second has passed with the hardware counter (CNTPCT_EL0), not with your tick count: ticks stop while the compositor copies with interrupts masked, so on the Pi they fall behind real time. - Count how often your Quest 4 code holds a window back because its program is mid-frame. On the Pi it will not be zero!
🤖 AI use/coding: allowed, including agentic coding. There’s very little code to write here, though.
Part B: a faster memcpy
The memcpy we gave you back in Project 1—look at kernel/utils.c—is a wrapper around a memmove that copies one byte at a time. That was simple and works fine when copying small numbers of bytes. But your compositor now calls this code on buffers of hundreds of kilobytes, sixty times a second!
Task: Make memcpy copy a machine word (8 bytes) at a time.
- Rewrite the kernel’s
memcpyto copy a machine word at a time, handling the unaligned bytes at each end. - Do the same for the
memcpyinuser/lib/u_string.c: your text console’s scrolling copies most of a canvas for every new line. - Test it before you trust it, against a byte-at-a-time copy (see the hints).
- Run your desktop on the Pi again, and compare with what you wrote down in Part A.
Hints
- Note that our
memcpyjust callsmemmove, so you can either changememmoveor givememcpyits own implementation, since you know your source and destination never overlap. (Which of those is the better choice, and why?) - The structure is: copy bytes until the destination is 8-byte aligned; copy
uint64s while at least 8 bytes remain; copy the remaining bytes. This should require about twenty lines. - Align both pointers, not just the destination, and fall back to a byte copy if you cannot—that is, if the two pointers are different distances from an 8-byte boundary. This is the one place in this quest where getting it wrong does not merely produce a wrong picture: with virtual memory off, the CPU does not tolerate unaligned accesses. An unaligned 64-bit load is not slow, it is an alignment fault—a synchronous exception with class
0x25inESR_EL1, landing in the handler you wrote in Project 2. If your OS starts panicking there the moment you touchmemcpy, alignment is probably why that happens. - Canvas rows and screen rows are 4-byte-aligned pixel arrays, and in our layout they happen to be 8-byte aligned too, so in practice the alignment loops will do little—but write them anyway, since
memcpyis used elsewhere. - With the caches off, every instruction is fetched from memory, so the number of instructions per byte copied matters more than you would think. Copying four words per trip round the loop, rather than one, removes most of the loop’s bookkeeping. (ARM64’s
ldp/stpinstructions move 16 bytes at a time, if you want to go further—but get the plain version working first.) - Test it. A
memcpythat is fast and subtly wrong shows up as a display with occasional garbage in it, which you will spend a long time debugging. A good test copies between every pair of source and destination alignments (0 to 15 bytes past a boundary, say) and a range of lengths—0, 1, 7, 8, 9, 31, 32, 33, a few hundred, a few thousand—checks every byte against a byte-at-a-time copy, and checks that the bytes just outside the destination were not touched. You can use AI to generate the testing code for you, but review it yourself!
🤖 AI use/coding: allowed, including agentic coding, for memcpy itself and for its test.
Why?
A word-at-a-time memcpy is a well-known piece of code with a well-known shape, and there is no operating systems content in it. Why your desktop was slow, and whether it is fast enough now, is the part we care about—and an AI cannot run your OS on the Pi for you.
For context, see the principles around AI use in CS 1670.
Part C: copy only what changed
Task: Make the compositor copy a window only if its canvas has changed.
- In your drawing library, set the canvas’s
changedflag in every function that draws—after it has written its pixels—and do the same anywhere else that writes pixels directly, such as your text console’s scrolling. pongand the clock do their own drawing, so they have to say so themselves: changepong_frame_done()andclock_frame_done()to set the flag on their canvas.- In the compositor, copy a window only if its flag is set, and clear the flag when you do. Then deal with the catch from the background section: a change has to reach both halves of the framebuffer, so a window whose flag was set must be copied again at the next refresh, into the other half, even if it has not changed since.
- Fit it together with Quest 4’s deferred updates to a window. If a window’s program is mid-drawing, leave its flag alone—the new frame is not finished yet—and if the window still owes the other half a copy, make that copy from the visible half, as before. Either way, its canvas should not be read.
- Your borders no longer need drawing on every refresh either. Redraw them only when the focus moves (your console can tell the compositor when that happens). Make sure to update them in both halves of the framebuffer for the same reason as everything else here.
Hints
- Why “after”? The compositor can look at any moment—including halfway through a drawing call—and when it looks, it copies the canvas and clears the flag. Set at the end of each call, the flag is set again after every last pixel, so the compositor always comes back for the finished picture: a half-copied frame lasts at most until the next refresh. Set at the start, a compositor that looked in the middle of the last call of a frame would clear the flag, copy half of that call’s work, and never hear about the rest—until the program next drew something. (For programs that call
wait_for_vsync(), Quest 4’s frame boundaries already stop the compositor reading their canvases mid-frame, but terminals and BASIC rely on the above behavior to be correct.) - A counter per window—“this many more copies owed”—is the simplest way to handle the two halves: set it to 2 when you find the flag set, and copy (and count down) whenever it is above 0. A copy from the visible half of the framebuffer for a held-back window decrements the counter to 0: afterwards, both halves of the framebuffer agree.
- Keep flipping on every refresh, even when nothing was copied. It is safe—once every window’s copies are paid off, the two halves hold the same picture—and under QEMU it matters: QEMU’s display is not synchronized with your flips at all, and in our experience it shows occasional torn frames more often if the compositor skips them.
- Check that it works, rather than assuming it. With the
DEBUG()line from Part A:pongshould be copied about once per refresh, the clock twice a second (once per half), and your terminal not at all until you type. If the clock is copied on every refresh, something is setting its flag that should not be. Ifpongfreezes, something is not setting it that should. - Think about what the flag trusts. A program that sets it on every frame for no reason costs the compositor time, and so costs everybody. A program that never sets it hurts only itself: its window just stops updating. Could the kernel find out what changed without asking? (It could compare, or checksum, every canvas on every refresh—but that means reading every pixel, which costs about as much as copying it. In Project 5 you will meet a piece of hardware that does it for free: the MMU’s dirty bit, which records whether a page of memory has been written.)
🤖 AI use/coding: auxiliary use allowed. You may use AI to discuss the design and to debug. Write the flag handling in the compositor yourself.
Why?
The interesting part of this is not the flag—it is the reasoning about why a change must be copied twice, and why the flag must be set after drawing. Both are about the same question as Quest 4: when, exactly, does a finished frame exist?
For context, see the principles around AI use in CS 1670.
Check your work
- Your output should still be correct. Everything in Quest 4’s checks still holds: there must be no tearing, no half-drawn frames, focus switching must work, BASIC must work in its window, and no process must draw outside its window.
- Unchanged windows are not copied. With your
DEBUG()line, you should see thatpongis copied about once per refresh (many times per second), the clock twice a second, and the terminal and BASIC are only copied when you type. - Your copies are right. Your
memcpytest passes, for both copies, and nothing on the screen ever shows garbage! - On the Pi, it is visibly better than what you saw in Part A:
pongmoves more smoothly, and your typing appears promptly.
Task: Capture a video of the performance your graphical desktop achieves on hardware. Connect your Raspberry Pi to a monitor or TV, and record the screen with your phone or a camera. Upload it to Google Drive, share it with the instructors (or anyone at Brown) and include a link to your video in a file called docs/desktop-performance.md and push it to Github.
This is part of your project submission.
🤖 AI use/coding: NOT allowed. The video must be real and clearly filmed by you.
Bonus Quests (Extra Credit)
Important note: All extra credit work requires you to meet with an instructor at office hours to discuss your work in order to get credit. There will be no exceptions to this, since extra credit is strictly optional.
Quest 5-E1: Get the copy out of the interrupt handler (moderate)
Task (extra credit): Restructure your compositor so that the timer interrupt handler does no copying. Add a dedicated kernel process—the compositor process—that blocks waiting for a “refresh due” wakeup, does the copying and the flip with interrupts enabled, and blocks again. The timer handler’s entire job becomes one wakeup() of that process now.
This raises a design question, though: your compositor now runs with interrupts on, which means it can be preempted halfway through a copy. Is that safe? What would go wrong, and does your double-buffering already protect you? And what happens to your frame-boundary check, now that the compositor is just another process, which the programs it is checking on may run in the middle of?
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Quest 5-E2: Damage rectangles (moderate–high)
Copying all of pong’s window every frame to update a box that covers a small part of it is, to put it mildly, inefficient.
Task (extra credit): Make damage detection finer-grained. Have your drawing library record not just whether a canvas changed, but the bounding rectangle of everything it drew since the compositor last looked, and have the compositor copy only that rectangle.
The two-halves catch from Part C is still there, and now it is even trickier to resolve: the hidden half is missing whatever changed in the last two refreshes, which are not the same rectangle. Work out what you need to copy, and then decide whether to keep a damaged rectangle per half or simply copy the union of the last two!
🤖 AI use/coding: allowed, including agentic coding. We generally allow AI for extra credit quests, but you must understand your implementation fully. To get credit, you will need to discuss your work with an instructor in person.
Open-ended extra credit: impress us!
Beyond the per-quest bonus quests above, we are happy to give extra credit for anything that makes your OS more interesting, more usable, or more fun, as long as you can explain how it works. Some directions, in no particular order:
- A game. You have a screen, a keyboard, a timer and preemption. Snake, Tetris, Breakout, and Conway’s Game of Life are all comfortably within reach, and all of them make good demonstrations.
- Start and stop programs from your terminal. A
run <name>command that allocates a process descriptor, loads one of the embedded ELF executables into it, assigns it a window, and marks it runnable—plus akill <pid>that marks a processUNUSEDand reclaims its window. This is the closest thing to a real shell you can build before Project 4. - A screensaver, which requires knowing how long it has been since the last keystroke, which requires the kernel to notice.
- Better input: support arrow keys and implement a proper line editor, a scrollback buffer in your terminal, or copy and paste between windows (the latter is much harder than you’d think!).
- Mouse support, if you are feeling brave and have a serial mouse or are willing to invent a protocol over the UART. Some people have managed to connect PS/2 mice to the Pi’s GPIO pins, and there are USB-to-serial adapters that work with a USB mouse. (We do not provide a mouse, so you’ll need your own.)
- Something we haven’t thought of. Surprise us!
🤖 AI use/coding: allowed, including agentic coding. As with all extra credit, you must understand your implementation fully, and you must meet with an instructor at office hours to discuss it in order to get credit.
For context, see the principles around AI use in CS 1670.
Handing in & grading
You will submit your work via git, using our grading server, exactly as for Projects 1 and 2.
Before you submit, make sure that:
make clean && make qemubuilds and runs without errors from a fresh checkout, and that your OS still copes iffb_init()fails.make clean && make qemu-fb(ormake qemu-ws) shows your windowed desktop.- Your code is formatted (
make format) and free of debugging spew you did not intend to leave in—in particular, remove the temporary drain from Quest 1 and any per-frame prints from your compositor. (Lines insideDEBUG()can stay: they cost nothing unless you build withDEBUG_LVL=1.) - Your memory layout diagram is up to date and committed as
docs/memlayout-physical.pdf. - Your screenshot from Quest 4 is committed as
docs/desktop.png(or.jpg).
Submitting your code (and checking on the grading server)
- Log into the grading server with the password you received.
- Connect your repository under the “Project 3: Input and Output” category, if you have not already, and click it to fetch your repository from GitHub.
- Check your code works: use the buttons below the commit list to test your code in our grading environment.
- Set your grading commit with the “Grade this commit” button. You can reassign it at any time before the 72 late hour cutoff.
- After submitting your work, sign up for a whiteboard discussion.
Extra requirements for CS 1690/2670 students
In addition to submitting the project, you must also check that your code works on the real hardware. Two parts of this project behave noticeably differently on a real Raspberry Pi than in QEMU, and you should test both early:
- Keyboard input over the real serial line. Your input now depends on the receive half of your UART configuration—the GPIO pin setup, the baud rate divisor, and flow control—which you have never exercised before. QEMU is far more forgiving of mistakes here than hardware.
- Graphics. Your compositor copies hundreds of kilobytes per frame, and on hardware, where your kernel runs with the CPU caches disabled, which is far slower than in emulation. That is what Quest 5 is about: run your desktop on the Pi early, both before and after Quest 5, and be ready to talk about what you see.
We recommend that you test your work with your own Raspberry Pi using a monitor or TV (as described for Part A). You can also use the “Run on hardware (with framebuffer) [1690/2670 only]” button on the grading server, or come to instructor office hours, but there is one remote-accessible Raspberry Pi and demand spikes before the deadline.
Note: The video output on the remote Pi has some quality limitations, and you may notice lag or lower framerates, especially if you are testing off-campus This doesn’t indicate a problem with your code, but rather a limitation of the capture setup (which is tricky, because of how we need to capture the video signal and send it to you via the network, which is a tough problem!). For more details, please see the blue box printed when you run the code, which reflects the current status of the capture tool. If you primarily test using the remote Pi, we recommend coming to office hours at least once to see how it looks on a display in-person–just for the experience!
Congratulations—your operating system now has a screen and a keyboard. It is, for the first time, a computer you can actually use! 🎉
Grading breakdown
Your grade is based on:
- Functionality: We will run your code on the grading server and check that it works as expected (i.e., you can provide keyboard input to programs, and the screen shows output from multiple programs). This is worth 50% of your grade and impacts the “Projects” part of the course grade.
- Whiteboard Discussion: You will meet with a course staff member in a small group to discuss the concepts in this project for 45 minutes. The discussion is worth 50% of your grade and impacts the “Whiteboard Discussions” part of the course grade.
- If you are in CS 1690/2670, the additional work you must complete will contribute 1/5 of your overall grade (i.e., the contributions of functionality and whiteboard discussion will be scaled to 40% of your grade each, and the extra work will be another 20%).
We will grade according to our honest grading scale.