Tracing the threads kernel with GDB

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:

  1. 01-entry.png
  2. 02-main-stack.png
  3. 03-uart-xv6.png
  4. 04-kalloc-freelist.png
  5. 05-procs.png
  6. 06-scheduler.png
  7. 07-memory-map.png

Your addresses may differ from the examples in this assignment. Use the addresses from your build.

Check your tools

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.

Connect GDB to QEMU

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.

Follow the kernel entry code

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

Move through start and into main

Execute 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

Trace three characters to the UART

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

Build the allocator’s first two free-list entries

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

Inspect the two new threads

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

Watch the scheduler choose each thread

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

Stop GDB and QEMU

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.

Add a kernel memory map

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 summary

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.

Submission checklist

Submit all seven PNG files. Before submitting, open each one and verify that the requested commands and results are readable: