userinit creates the very first user process in the system. It’s the seed from which everything else grows.
void userinit(void) {
struct proc *p;
p = allocproc();
initproc = p;
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}Short, but a lot happens underneath.
allocproc()
This does the heavy lifting. It scans the process table for an UNUSED slot, and when it finds one:
Assigns a PID by calling allocpid (which increments nextpid under the pid_lock). Sets the state to USED so no one else grabs this slot.
Allocates a trapframe page with kalloc. The trapframe is where the kernel saves and restores user registers during traps — it’s the bridge between user and kernel register state. It gets mapped at a fixed virtual address (TRAPFRAME) in the process’s page table.
Creates a user page table by calling proc_pagetable. This allocates an empty page table and maps two things into it: the trampoline at the very top of the virtual address space (same physical page as the kernel’s trampoline mapping, so trap code works across page table switches), and the trapframe just below it. No user code or data is mapped yet — the address space is empty.
Sets up the process’s context so that when the scheduler first switches to this process, it starts executing at forkret:
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;ra is the return address register — when swtch returns, it jumps to forkret. sp points to the top of this process’s kernel stack (stacks grow down, so “top” means the highest address, which is kstack + PGSIZE).
Returns with p->lock held.
Back in userinit
initproc = p stores a global pointer to this process. The kernel uses initproc later — when a process exits, its orphaned children get reparented to initproc. The init process is the adoptive parent of everything.
p->cwd = namei("/") sets the process’s current working directory to the root of the filesystem. namei looks up a path and returns the inode. But wait — the filesystem isn’t initialized yet. fsinit hasn’t been called. How does this work?
It doesn’t do a real filesystem lookup right now. namei calls iget, which just finds an empty slot in the inode table and fills in the device and inode number. The inode’s valid field stays 0 — the actual data hasn’t been read from disk. The real disk read happens later when someone calls ilock on this inode.
p->state = RUNNABLE makes the process eligible to be scheduled. The scheduler will find it on its next scan.
release(&p->lock) drops the lock that allocproc was holding.
What doesn’t happen here
There’s no user code loaded. No binary, no stack, no arguments. The process has an empty address space with just the trampoline and trapframe mapped. The actual loading of /init happens later, inside forkret.
The path to actually running
When the scheduler picks this process and swtches to it, execution jumps to forkret. Inside forkret, the first flag is true, so it calls fsinit (initializing the filesystem — this couldn’t happen in main() because it calls sleep, which requires a process context). Then it calls kexec("/init", ...) which loads the /init binary from disk into this process’s address space — reading the ELF header, allocating pages, mapping code and data, setting up a user stack. After that, forkret jumps through the trampoline into user space, and the init process starts running.
So userinit doesn’t really create a runnable user process. It creates the skeleton — a process struct with a PID, empty page table, trapframe, and context pointing at forkret. The actual user program gets loaded when the skeleton first gets scheduled.