In this assignment you will follow the threads kernel from its first instruction through boot, page allocation, thread creation, and scheduling. You will use GDB to inspect instructions, registers, variables, pointers, and memory. You will finish by adding a small memory-map report to the kernel.
Submit these seven screenshots with exactly these names:
01-entry.png02-main-stack.png03-uart-xv6.png04-kalloc-freelist.png05-procs.png06-scheduler.png07-memory-map.pngYour addresses may differ from the examples in this assignment. Use the addresses from your build.
Work in the top-level 3-threads directory, the directory containing
Makefile and kernel.
Linux users should install the packages listed under Linux users on the
course setup page. macOS users should follow the MacOS users
section on that page. In particular, macOS users must make the two documented
Makefile changes that define GDB and print the correct debugger command.
First build and run the kernel normally:
make clean
make
make qemu
A successful run prints the boot message followed by alternating ping and
pong lines. Press Control-a, release both keys, and then press x to quit
QEMU. make clean removes generated build files. make compiles and links the
kernel. make qemu runs it in the QEMU RISC-V emulator.
If a command is missing or the build fails, return to the
course setup page and check every step for your operating system.
Read the first error from make; later errors are often consequences of the
first one. You may also use an LLM to help diagnose setup problems. Give it:
Ask it to explain the error and propose one check at a time. Do not let it replace the kernel Makefile with one from another xv6 version.
Kernel debugging uses two terminals because QEMU and GDB are separate programs:
Open two terminals in the same 3-threads directory.
In Terminal 1, run:
make qemu-gdb
QEMU will wait with a blank screen because qemu-gdb starts the CPU paused.
The command also creates .gdbinit, which contains the correct port, target
architecture, and kernel symbol file.
In Terminal 2, Linux users should run:
gdb-multiarch -x .gdbinit
macOS users should run the command printed by make qemu-gdb. With the course
Makefile changes, it will normally be:
riscv64-elf-gdb -x .gdbinit
The -x .gdbinit option tells GDB to execute the commands in that file. GDB
may warn that automatic loading of the local .gdbinit was declined. That
warning is harmless here because -x .gdbinit explicitly loads the file. It
may also briefly warn that no executable was specified while it connects;
.gdbinit loads the kernel’s symbols immediately afterward.
When the (gdb) prompt appears, run this connection smoke test:
info registers pc
x/4i $pc
pc is the program counter. info registers pc asks the live target for that
register. x/4i $pc examines four instructions beginning at the address in
pc. A successful connection displays a pc near 0x1000 and four RISC-V
instructions. The CPU begins in QEMU’s small reset sequence, not in the kernel.
If GDB says that no target is connected or cannot read registers or memory,
stop and fix the connection before continuing.
Set a temporary breakpoint at the kernel’s first instruction and resume the CPU:
tbreak _entry
continue
A breakpoint stops execution at an address. tbreak makes a temporary
breakpoint that deletes itself after its first hit. continue resumes the CPU
until it reaches a breakpoint.
GDB should stop in kernel/entry.S. Look at the source and the actual
instructions produced by the assembler:
list
disassemble _entry
The linker script places _entry at 0x80000000. The first two assembly
instructions implement the la sp, stack0 pseudo-instruction. The next two
load 4096 and add it to sp. Stacks grow toward lower addresses, so the
initial stack pointer is one byte past the top of the 4096-byte stack0
array.
Print the bottom and top of that array:
p/x (unsigned long)stack0
p/x (unsigned long)stack0 + sizeof stack0
p evaluates and prints an expression. /x selects hexadecimal output.
sizeof stack0 comes from the debug type information.
Display the next instruction automatically, then execute the four instructions that establish the stack:
display/i $pc
si
si
si
si
info registers sp a0 pc
display/i $pc shows the instruction at pc every time execution stops. si
means step one machine instruction. After the four steps, a0 should be
4096 and sp should equal the top of stack0 printed above. The next
instruction should call start.
Take a screenshot showing the bottom and top of stack0, the four stepped
instructions, and the final register values. Save it as 01-entry.png.
Commands introduced
| Command | Purpose |
info registers pc |
Print selected registers from the live CPU. |
x/4i $pc |
Examine four instructions at pc. |
tbreak location |
Break once at a source or symbol location. |
continue |
Resume execution. |
list |
Show source near the current location. |
disassemble function |
Show a function’s machine instructions. |
p/x expression |
Evaluate an expression in hexadecimal. |
display/i $pc |
Show the next instruction at every stop. |
si |
Execute one machine instruction. |
Techniques introduced
start and into mainExecute the call instruction to enter start, then show its source:
si
list
start runs in machine mode. It selects supervisor mode for the next privilege
transition, puts the address of main in mepc, disables paging, grants
supervisor mode access to physical memory, and executes mret. You can inspect
the relevant control and status registers with:
info registers mstatus mepc satp
Do not spend time stepping through every instruction in start. Remove the
automatic instruction display, set a temporary breakpoint at main, and run
to it:
undisplay 1
tbreak main
continue
GDB stops after the function prologue has reserved a small amount of stack space. Compare the current stack pointer with the same boot-stack bounds:
info registers sp
p/x (unsigned long)stack0
p/x (unsigned long)stack0 + sizeof stack0
p/d ((unsigned long)stack0 + sizeof stack0) - (unsigned long)$sp
/d selects decimal output. The final expression reports how far sp has
moved below the top of stack0. It should be a small positive number, and sp
should still be between the bottom and top of the array. The exact difference
can vary with the compiler.
Take a screenshot showing these four results. Save it as
02-main-stack.png.
Commands introduced
| Command | Purpose |
info registers mstatus ... |
Print several named registers. |
undisplay 1 |
Remove automatic display number 1. |
p/d expression |
Evaluate an expression in decimal. |
Techniques introduced
sp from the stack’s top.Skip consoleinit and printkinit, along with the first blank line, by
running to the boot-message call in main:
tbreak kernel/main.c:13
continue
Step into printk, advance over va_start, and inspect its format string:
step
next
p fmt
x/s fmt
list 46,60
step executes the current source line and enters a called function. next
executes a source line without entering its calls. x/s examines memory as a
null-terminated string. The loop in printk takes one character at a time
from fmt and passes ordinary characters to consputc.
Set a one-use breakpoint at the ordinary-character call to uartputc_sync:
tbreak kernel/console.c:19
continue
p/c c
step
/c formats an integer as both a number and a character. The first character
is 120 'x'. step follows the call into uartputc_sync.
The loop in uartputc_sync reads the UART line-status register until its
transmit-holding-register-ready bit is set. Line 64 executes only after that
condition becomes true. The argument c is then ready to be written to the
UART’s memory-mapped transmit register. Set a regular breakpoint there:
break kernel/uart.c:64
continue
p/c c
continue
p/c c
continue
p/c c
Unlike tbreak, break remains active. The three stops should show x, v,
and 6 in order.
Take a screenshot showing all three characters. Save it as
03-uart-xv6.png.
Delete the UART breakpoint, then run directly to the kinit call in main:
clear kernel/uart.c:64
tbreak kernel/main.c:16
continue
clear deletes breakpoints at the given source location. Running to main
is faster than returning through uartputc_sync, consputc, and printk one
function at a time.
Commands introduced
| Command | Purpose |
step |
Execute a source line and enter calls. |
next |
Execute a source line without entering calls. |
p expression |
Evaluate using GDB’s default format. |
x/s address |
Examine a null-terminated string. |
p/c expression |
Print a value as a character. |
break location |
Set a breakpoint that remains active. |
clear location |
Delete breakpoints at a location. |
Techniques introduced
You are stopped at the call to kinit. Read kernel/kalloc.c before
continuing. The linker script defines end as the first address after the
kernel image. freerange rounds that address up to a page boundary. It gives
every complete page from that boundary through PHYSTOP to kfree.
GDB does not know C preprocessor macros from this build, so calculate
PHYSTOP from its definition in kernel/memlayout.h: 128 MiB after
KERNBASE, which is 0x80000000.
Create GDB convenience variables for the range and print its size:
p/x (unsigned long)end
set $first_page = ((unsigned long)end + 4095) & ~(unsigned long)4095
set $phystop = (unsigned long)0x80000000 + 128 * 1024 * 1024
set $free_bytes = $phystop - $first_page
p/x $first_page
p/x $phystop
p/d $free_bytes / 4096
p/d $free_bytes
p/f (double)$free_bytes / (1024 * 1024)
Names beginning with $ are convenience variables that exist only in GDB.
The first expression is the PGROUNDUP calculation from kernel/riscv.h.
The last three values are the number of pages, number of bytes, and number of
MiB in the initial allocation range. /f selects floating-point output.
Stop on the first call to kfree:
break kfree
continue
p/x pa
p/x freelist
pa should equal $first_page, and freelist should still be null. Run until
this call to kfree returns, then examine the new list head and the pointer
stored in its first eight bytes:
finish
p/x freelist
x/1gx freelist
finish continues until the current function returns. Use it here only while
stopped inside kfree. In x/1gx, 1 means one item, g means an eight-byte
giant word, and x means hexadecimal. The first page is now the list head and
contains a null next pointer.
Continue to the second call and let it finish:
continue
p/x pa
finish
The second page was inserted at the head. Repeat the size calculations and follow both pointer links:
p/d $free_bytes / 4096
p/d $free_bytes
p/f (double)$free_bytes / (1024 * 1024)
p/x freelist
p/x freelist->next
p/x freelist->next->next
x/1gx freelist
x/1gx freelist->next
The head should be the second page. Its next points to the first page, whose
next is null. The two x/1gx commands show the same links as raw memory.
Take a screenshot showing the page, byte, and MiB counts followed by the list
head and both valid entries. Save it as 04-kalloc-freelist.png.
Remove the breakpoint and run to the next line in main:
clear kfree
tbreak kernel/main.c:17
continue
This continuation initializes almost 128 MiB and may take a few seconds under
the debugger. Do not use finish from freerange; optimized inline code can
make that operation much slower.
Commands introduced
| Command | Purpose |
set $name = expression |
Save a value in a GDB convenience variable. |
p/f expression |
Print a floating-point value. |
finish |
Continue until the current call returns. |
x/1gx address |
Read one eight-byte value in hexadecimal. |
pointer->field |
Follow a typed pointer to a structure field. |
Techniques introduced
You are back in main, immediately before procinit. Skip initialization,
the Hello, world! message, and the calls inside thread_demo:
tbreak kernel/main.c:22
continue
This stops at the call to scheduler, after thread_demo has created both
threads. Ask GDB to describe the type, disable output pagination, enable
readable structure formatting, and print a few process-table entries:
ptype struct proc
set pagination off
set print pretty on
p proc[0]
p proc[1]
p proc[2]
ptype shows a type definition. A whole struct proc is useful for initial
orientation, but targeted expressions are easier to compare. The first two
entries should be RUNNABLE; the third should be UNUSED:
p proc[0].state
p proc[1].state
p proc[2].state
Each new context starts with its ra set to the thread’s entry function. The
name array is a short debugging string. Press Control-l to clear the GDB
terminal, then stage a compact comparison:
p proc[0].state
printf "proc[0] pid=%d name=%s\n", proc[0].pid, proc[0].name
p/x proc[0].context.ra
p/x (unsigned long)ping
p proc[1].state
printf "proc[1] pid=%d name=%s\n", proc[1].pid, proc[1].name
p/x proc[1].context.ra
p/x (unsigned long)pong
GDB’s printf formats several values on one line; it does not call the
kernel’s printk. The saved ra for each process should exactly match the
address of the corresponding function.
Take a screenshot showing both RUNNABLE states, both names, both saved ra
values, and both function addresses. Save it as 05-procs.png.
Commands introduced
| Command | Purpose |
ptype type |
Show a type’s definition. |
set pagination off |
Let long output print without pausing. |
set print pretty on |
Format structures across readable lines. |
p object.field |
Print one selected structure field. |
printf format, values... |
Format debugger values on one line. |
Techniques introduced
Read the inner loop in scheduler in kernel/proc.c. It changes a runnable
process to RUNNING, assigns it to current_proc, and then calls swtch.
Stop immediately before that call:
break kernel/proc.c:88
continue
printf "about to run: %s\n", p->name
The local pointer p identifies the selected process. The first stop should
print ping. Continue to the same breakpoint for the next selected process:
continue
Press the Up-arrow key until the previous printf command reappears, then
press Enter. It should now print pong. GDB retains a command history, so the
Up-arrow key avoids retyping commands while repeatedly visiting a breakpoint.
Take one screenshot that contains both breakpoint hits and both about to
run lines. Save it as 06-scheduler.png.
Commands introduced
| Command | Purpose |
| Up-arrow | Recall earlier GDB commands. |
Techniques introduced
At the GDB prompt, run:
quit
Then move to Terminal 1 and press Control-a, release both keys, and press x. If quitting GDB already ended QEMU, Terminal 1 will already have returned to its shell prompt.
You will now add a kernel source file that prints a compact table. Open a new
file named kernel/memory_map.c in your editor and enter this code:
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "proc.h"
#include "defs.h"
extern char _entry[];
extern char etext[];
extern char end[];
extern char stack0[PGSIZE];
extern struct proc proc[NPROC];
static void
print_region(char *name, uint64 start, uint64 region_end)
{
printk("%s %p %p %lu\n", name, (void *)start, (void *)region_end,
region_end - start);
}
void
memory_map_dump(void)
{
struct proc *p;
printk("\nKernel memory map\n");
printk("region start end bytes\n");
print_region("physical RAM ", KERNBASE, PHYSTOP);
print_region("kernel text ", (uint64)_entry, (uint64)etext);
print_region("kernel image ", (uint64)_entry, (uint64)end);
print_region("boot stack ", (uint64)stack0,
(uint64)stack0 + sizeof(stack0));
print_region("proc table ", (uint64)proc, (uint64)&proc[NPROC]);
print_region("allocation range ", PGROUNDUP((uint64)end), PHYSTOP);
for (p = proc; p < &proc[NPROC]; p++) {
if (p->state == UNUSED)
continue;
printk("thread %s stack %p %p %d\n", p->name, p->kstack,
(void *)((uint64)p->kstack + PGSIZE), PGSIZE);
}
}
The declarations beginning with extern name objects defined elsewhere. The
linker script starts its first section at 0x80000000, and entry.o places
_entry first in that section. It defines etext at the end of the text
section and end after the kernel’s final section. PHYSTOP comes from
kernel/memlayout.h.
The process-table loop is safe here because this kernel has one CPU, interrupts are disabled, and the function will run before the scheduler starts any thread. Each used process-table entry owns one page-aligned kernel stack.
Declare the new public function in kernel/defs.h. Add a section near the
other file-based declarations:
// memory_map.c
void memory_map_dump(void);
Add the new object file to OBJS in Makefile. A suitable location is after
$K/proc.o:
$K/proc.o \
$K/memory_map.o \
$K/swtch.o \
Finally, call the new function in kernel/main.c after the thread setup but
before the scheduler:
thread_demo();
memory_map_dump();
scheduler();
Build and run the changed kernel:
make
make qemu
The table should include physical RAM, kernel text, the complete kernel image,
the boot stack, the process table, the initial page-allocation range, and the
two thread stacks. The allocation range row describes the range initially
given to the allocator; by the time this table prints, the two pages at the
top of that range belong to ping and pong.
Take a screenshot showing the complete table and both thread-stack rows. Save
it as 07-memory-map.png. Quit QEMU with Control-a followed by x.
Commands and tools introduced
| Command or tool | Purpose |
kernel/memory_map.c |
Add one compilation unit to the kernel. |
Declaration in defs.h |
Make a function visible to other C files. |
Object in OBJS |
Include the compiled file in the kernel link. |
make |
Rebuild changed and dependent files. |
make qemu |
Run the completed kernel normally. |
Techniques introduced
| Command | Purpose |
make clean |
Remove generated build files. |
make |
Build the kernel. |
make qemu |
Run the kernel normally. |
make qemu-gdb |
Run QEMU paused with its GDB server. |
gdb-multiarch -x .gdbinit |
Start Linux GDB and connect to QEMU. |
riscv64-elf-gdb -x .gdbinit |
Start the usual macOS GDB command. |
info registers names... |
Read live CPU registers. |
list |
Show source code. |
disassemble function |
Show machine instructions. |
display/i $pc |
Show the next instruction at every stop. |
undisplay number |
Remove an automatic display. |
break location |
Set a persistent breakpoint. |
tbreak location |
Set a one-use breakpoint. |
clear location |
Delete breakpoints at a location. |
continue |
Run until the next stop. |
si |
Execute one machine instruction. |
step |
Execute a source line and enter calls. |
next |
Execute a source line without entering calls. |
finish |
Run until the current function returns. |
p, p/x, p/d, p/c |
Print in default, hex, decimal, or character. |
p/f expression |
Print a floating-point value. |
x/4i, x/s, x/1gx |
Examine instructions, strings, or raw words. |
ptype type |
Show a type definition. |
set $name = expression |
Save a debugger calculation. |
set pagination off |
Print long results without stopping. |
| Up-arrow | Recall an earlier command. |
printf format, values... |
Produce compact debugger output. |
quit |
Leave GDB. |
By the end of this assignment, you have followed data in four forms: assembly instructions in text memory, live CPU registers, typed C data structures, and raw memory. You have also used breakpoints to move quickly between important events instead of stepping through every instruction.
Submit all seven PNG files. Before submitting, open each one and verify that the requested commands and results are readable:
01-entry.png: boot-stack bounds and the loaded sp.02-main-stack.png: live sp, stack bounds, and distance from the top.03-uart-xv6.png: the characters x, v, and 6 at the UART write.04-kalloc-freelist.png: range sizes and the first two linked pages.05-procs.png: both runnable entries, names, saved ra, and functions.06-scheduler.png: consecutive scheduler choices for ping and pong.07-memory-map.png: the complete table including both thread stacks.