Two Weeks with Operating Systems: From Processes to xv6 Traps(generated by chatgpt)

Over the past two weeks, I have been learning operating systems through lectures, OSTEP, RISC-V, and especially the xv6 labs. At first, many concepts felt completely disconnected: processes, file descriptors, page tables, traps, registers, and interrupts all seemed to live in different worlds.

After working through xv6, I am starting to see them as different parts of the same machine.

The basic picture is:

Process
System Call
CPU Privilege Boundary
Trap
Kernel
Memory / Devices / Scheduling

This post is a summary of what I have learned so far.


1. What is a Process?

A process is not simply a program.

A running process consists of several pieces of state:

  • Code
  • Static data
  • Heap
  • Stack
  • Registers
  • Address space / page table
  • File descriptor table
  • PID
  • Parent process
  • Process state

The operating system gives each process the illusion that it has its own machine and its own memory.

This is one of the most important abstractions in an OS:

A process is a running program together with the state needed to control and isolate it.


2. fork, wait, and exec

The first process interface I learned was fork().

int pid = fork();

fork() creates a new child process that is initially almost a copy of the parent.

The interesting part is that both processes continue executing after fork(), but they see different return values:

parent: fork() → child PID
child:  fork() → 0

The child gets its own address space and process state, even though its initial memory contents resemble the parent’s.

Then there is wait().

A parent can wait for a child to terminate:

Parent
  │ wait()
Blocked
  │ child exits
Ready / Running

The child may temporarily become a zombie after exiting, until the parent collects its status with wait(). The parent can therefore sleep rather than continuously checking the child’s state.

Finally, exec() does something very different from fork():

fork() creates a process; exec() replaces the program being executed by the current process.

This gives the classic Unix pattern:

fork()
child
exec()
run another program

This is exactly what a shell uses to launch commands.


3. File Descriptors and Pipes

A file descriptor is an integer handle to an underlying resource.

The standard descriptors are:

0 → stdin
1 → stdout
2 → stderr

This abstraction becomes especially powerful with dup() and pipe().

A pipe creates two ends:

fd[1] ── write ──→ [ kernel buffer ] ── read ──→ fd[0]

The kernel keeps the written data in a buffer until another process reads it.

The really interesting part is that dup() can change what a process’s standard input or output refers to.

For example:

close(1)
dup(pipe_write_fd)

If 1 was the lowest available descriptor, dup() makes the pipe become the new stdout.

This is how programs such as

cat file | head

can be connected without changing the implementation of either cat or head.

The shell changes their file-descriptor environments before calling exec().

This made me realize that a Unix program does not necessarily need to know where its input and output physically come from. It only needs to know which file descriptors it is using.


4. System Calls: Crossing the User/Kernel Boundary

A normal user program cannot directly perform privileged operating-system operations.

Instead, it asks the kernel through system calls.

For example:

getpid();
sleep(10);
sbrk(...);

At the RISC-V level, a system call eventually uses:

ecall

The basic flow is:

User program
    │ ecall
CPU switches to supervisor mode
Trap handler
Kernel syscall dispatcher
sys_getpid(), sys_sleep(), ...
return to user

The syscall number is placed in a register such as a7, while arguments are passed through registers according to the calling convention. The kernel then retrieves those values and dispatches to the corresponding sys_* function.

One small but important lesson here was that the functions declared in user.h are not the actual kernel implementations. The user-side syscall wrapper eventually executes ecall, and the kernel uses the syscall number to find the implementation.

For example, conceptually:

user code
getpid()
a7 = SYS_getpid
ecall
syscall()
sys_getpid()

5. RISC-V Privilege Modes and stvec

The OS needs privileges that ordinary applications do not have.

This is why the CPU provides different privilege modes. In xv6, user programs run in user mode, while the kernel runs in supervisor mode.

A trap is the mechanism that allows the CPU to transfer control to the operating system when something important happens:

  • a system call
  • an interrupt
  • an exception

One register became particularly important to me:

stvec

stvec is a RISC-V CSR containing the address of the trap entry point.

It is not part of struct proc. It belongs to the current hardware thread (hart).

The key idea is:

trap happens
CPU reads stvec
jump to the address stored there

But stvec does not always point to the same code.

When a user process is running:

stvec → uservec

When the kernel itself is running:

stvec → kernelvec

This distinction initially confused me a lot.


6. uservec, usertrap, and kernelvec

uservec is not a register.

It is a label for assembly code in trampoline.S.

So the relationship is:

stvec
  │ contains an address
uservec
  │ assembly code
usertrap()

The complete user-side path is therefore:

User
  │ syscall / interrupt / exception
CPU
  │ read stvec
uservec
usertrap()
Kernel

If the kernel itself generates a trap:

Kernel
trap
kernelvec
kerneltrap()

So the general idea is:

stvec tells the CPU where to enter trap handling; uservec and kernelvec are different entry points for traps depending on whether the CPU was running user code or kernel code.


7. Why Does usertrapret() Modify stvec?

This was one of today’s biggest points of confusion.

At first, I thought:

If stvec is used when going from user to kernel, why are we changing it in usertrapret()?

The answer is that stvec belongs to the CPU, and its value changes according to the current execution context.

After entering the kernel from user space, xv6 uses:

stvec → kernelvec

because a trap occurring while the kernel is already running should be handled as a kernel trap.

But eventually the kernel finishes handling the trap and wants to return to user space.

That is what usertrapret() prepares for.

It does:

w_stvec(TRAMPOLINE + (uservec - trampoline));

Conceptually, this means:

stvec = address of uservec

The expression

TRAMPOLINE + (uservec - trampoline)

computes the address of uservec in the trampoline mapping.

So the sequence becomes:

kernel
  │ usertrapret()
stvec → uservec
userret
sret
user

Now, when the user process generates the next trap:

user
trap
read stvec
uservec
usertrap()

The important distinction is:

stvec is not code. It is an address telling the CPU where trap handling should begin.


8. The Trampoline

Another concept that initially felt mysterious was the trampoline.

The problem is that when a trap occurs in user mode, the CPU must eventually switch from the user page table to the kernel page table.

But the CPU cannot simply execute arbitrary kernel code before performing that transition.

xv6 therefore puts a small piece of assembly code, the trampoline, at a carefully chosen virtual address. Both the user and kernel page tables map this region.

The rough flow is:

User page table
  uservec
      ├── save registers
      ├── switch page table
      └── enter kernel
          usertrap()

When returning:

kernel
usertrapret()
userret
restore registers
switch to user page table
sret
user

This made the purpose of the trampoline much clearer to me:

It is a small piece of code that exists at the boundary between user and kernel address spaces.


9. Trap Frames

A trap interrupts a running user program, but the program has to continue later as if nothing happened.

Therefore, xv6 needs to save the user CPU state.

This state is represented by the trap frame.

Conceptually:

User registers
   trap
save state
trapframe
kernel handles trap
restore state
user continues

The trap frame contains information such as the saved program counter and registers needed to reconstruct the interrupted execution.

This idea became particularly important in the alarm lab.


10. Virtual Memory and Page Tables

The next major idea was memory abstraction.

Physical RAM has a finite range of physical addresses, but processes should see their own isolated address spaces.

The CPU therefore works with:

Virtual Address
   Page Table
Physical Address

Different processes can map their virtual addresses to different physical pages.

This gives two important properties:

  1. Isolation — one process cannot normally access another process’s memory.
  2. Abstraction — a process can act as if it has a large, continuous address space even though physical memory is shared.

The lecture summarized this as the OS giving processes an isolated view of memory and a much larger virtual view than physical RAM.


11. Heap, Stack, and Free Lists

I also started looking at memory allocation.

At the application level we have APIs such as:

void *malloc(size_t size);
void free(void *ptr);

But malloc() itself does not magically create memory. A heap allocator has to manage regions of memory internally.

One important idea is metadata.

An allocator may put a small header before an allocated region:

┌──────────────┬────────────────────┐
│   metadata   │   user allocation  │
└──────────────┴────────────────────┘

The metadata can contain the size of the allocation and other information. When free(ptr) is called, the allocator can move backward from ptr to find the header and recover the size.

A free list is then a linked list describing available chunks of memory:

head
[free chunk] → [free chunk] → [free chunk]

When allocating memory, a sufficiently large free chunk can be split. When freeing memory, the chunk is inserted back into the free list.

Without coalescing adjacent free chunks, memory can become fragmented even when the total amount of free memory is large.

This connected the abstract malloc/free interface with actual memory-management data structures.


12. The xv6 Alarm Lab

The alarm lab was where many of these ideas finally came together.

The goal is to implement a user-level alarm:

sigalarm(interval, handler);

Every specified number of timer ticks, the kernel should temporarily interrupt the current user program and run its user-level handler.

Conceptually:

normal user execution
timer interrupt
uservec
usertrap
alarm triggered?
modify saved user state
return to user
handler()
sigreturn()
restore original user state
continue interrupted program

The interesting part is that the handler itself runs in user space.

The kernel does not simply call an arbitrary C function as if it were normal kernel code. Instead, it modifies the saved user execution state so that, when control returns to user mode, execution begins at the handler.


13. Why sigreturn() Is Necessary

Suppose the original program was executing here:

A → B → C → D

A timer interrupt happens at C.

The kernel saves the original state and redirects execution to:

handler()

After the handler finishes, the original program should continue from approximately:

C → D

not restart from the beginning, and not continue using the handler’s modified register state.

Therefore, the kernel needs a saved copy of the interrupted user state.

This is why the alarm lab is fundamentally a trap-frame manipulation problem.


14. My Alarm Debugging Process

The most useful part of the lab was probably debugging it.

At one point, the alarm tests were partially successful:

alarmtest: test0: OK
alarmtest: test1: OK
alarmtest: test2: OK

usertests: FAIL

This was interesting because it meant that the basic alarm mechanism worked, but something about the complete system behavior was still wrong.

The main suspicion eventually became the return path.

The critical sequence was:

timer interrupt
usertrap()
alarm handler
sigreturn()
restore saved state
return to user

My first instinct was essentially:

“If I save the entire trap frame and restore it later, shouldn’t that be enough?”

But the important question is not just what state is restored. It is also:

What exact execution state should the CPU return to, and through which path?

That forced me to understand usertrapret(), userret, sret, sepc, sstatus, satp, and the trampoline much more carefully.

The debugging process therefore changed from:

"Which variable is wrong?"

to:

"What is the complete control-flow path?"

That was probably the most valuable lesson of the lab.


15. usertrapret() in One Picture

The function now makes much more sense when viewed as a checklist.

void
usertrapret(void)
{
    struct proc *p = myproc();

    intr_off();

    // Prepare for the next trap from user space.
    w_stvec(TRAMPOLINE + (uservec - trampoline));

    // Tell uservec how to get back into the kernel.
    p->trapframe->kernel_satp = r_satp();
    p->trapframe->kernel_sp = p->kstack + PGSIZE;
    p->trapframe->kernel_trap = (uint64)usertrap;
    p->trapframe->kernel_hartid = r_tp();

    // Configure sret to return to user mode.
    unsigned long x = r_sstatus();
    x &= ~SSTATUS_SPP;
    x |= SSTATUS_SPIE;
    w_sstatus(x);

    // Resume at the saved user PC.
    w_sepc(p->trapframe->epc);

    // Prepare the user page table.
    uint64 satp = MAKE_SATP(p->pagetable);

    // Enter trampoline code.
    uint64 fn = TRAMPOLINE + (userret - trampoline);
    ((void (*)(uint64, uint64))fn)(p->trap_va, satp);
}

The important thing is not to memorize every line.

Instead:

usertrapret()
    ├── prepare next user trap
    │       stvec → uservec
    ├── prepare trapframe
    ├── prepare sstatus
    ├── prepare sepc
    ├── prepare user page table
  userret
   sret
   user

It is essentially a return-to-user-state preparation function.


16. What I Understand Now

The biggest change in my understanding over these two weeks is that operating systems are less about isolated APIs and more about control flow across abstraction boundaries.

A process is an abstraction over execution.

A file descriptor is an abstraction over resources.

A page table is an abstraction over physical memory.

A system call is a controlled transition from user space to the kernel.

A trap is the hardware mechanism that transfers control to the OS.

The trampoline connects different address spaces and privilege contexts.

And the trap frame preserves enough state for the interrupted program to continue later.

The overall picture is now something like:

                    ┌──────────────────┐
                    │   User Process   │
                    └────────┬─────────┘
                    syscall / interrupt
                          stvec
                 ┌───────────┴───────────┐
                 ↓                       ↓
              uservec                kernelvec
                 ↓                       ↓
             usertrap()             kerneltrap()
                 │                       │
                 └───────────┬───────────┘
                          Kernel
                      usertrapret()
                         userret
                           sret
                    ┌──────────────────┐
                    │   User Process   │
                    └──────────────────┘

And the alarm lab sits directly inside this picture:

User program
Timer interrupt
uservec
usertrap
save user state
redirect to alarm handler
handler
sigreturn
restore user state
usertrapret / userret
continue user program

I still have a lot to learn about scheduling, synchronization, file systems, and virtual memory. But after these two weeks, xv6 no longer feels like a pile of mysterious C and assembly files. It is starting to look like a machine with a consistent set of rules.