Implementing Process-Specific Kernel Page Tables and Hardware-Accelerated Memory Copying in RISC-V

RISC-V SV39 Page Table Mechanics

In traditional operating system designs, transferring data between user space and kernel space requires software-based page table traversal (e.g., copyin and copyout). This software approach simulates the three-level page table walk, which is inherently slower than utilizing the hardware Memory Management Unit (MMU) and Translation Lookaside Buffer (TLB). By mapping user virtual addresses directly into the kernel's page table, the CPU can leverage hardware traversal for these cross-space memory operations, significantly improving efficiency.

Virtual to Physical Address Translation

The RISC-V SV39 architecture utilizes 39-bit virtual addresses. A virtual address is divided into three 9-bit indices (L2, L1, L0) for the three page table levels, and a 12-bit offset within the physical page.

  • Page Table Entry (PTE): Each PTE occupies 4 bytes and contains a 44-bit Physical Page Number (PPN) along with 10 flag bits.
  • Translation Process: The MMU reads the root page table base address from the satp register. It uses the L2 index to locate the second-level page table base, the L1 index for the third-level, and the L0 index to find the final physical page base. The 12-bit offset is then added to this base physical address to form the complete 56-bit physical address.
  • Multi-level Advantage: A three-level structure avoids allocating a massive contiguous memory block for a single-level table (which would require 2^27 entries). Instead, it only allocates page table pages as needed, each holding 512 entries.

Dumping Page Table Contents

The first task involves implementing a function to print the active page table hierarchy, displaying the depth, PTE indices, and corresponding physical addresses.

We can model this after the existing freewalk function. In freewalk, a PTE is considered a leaf node if it possesses any read, write, or execute permissions (PTE_R | PTE_W | PTE_X). Intermediate PTEs pointing to lower-level tables lack these permissions. By recursively traversing valid PTEs (PTE_V) that are non-leaf nodes, we can print the entire structure.

void dump_pagetable(pagetable_t pt) {
    printf("page table %p\n", pt);
    recursive_dump(pt, 1);
}

void recursive_dump(pagetable_t pt, int current_depth) {
    for (int idx = 0; idx < 512; idx++) {
        pte_t entry = pt[idx];
        if (entry & PTE_V) {
            uint64 child_pa = PTE2PA(entry);
            // Generate depth prefix
            char prefix[16];
            memset(prefix, 0, sizeof(prefix));
            for (int d = 0; d < current_depth; d++) {
                strcat(prefix, ".. ");
            }
            printf("%s%d: pte %p pa %p\n", prefix, idx, entry, child_pa);

            // If not a leaf node, recurse deeper
            if ((entry & (PTE_R | PTE_W | PTE_X)) == 0) {
                recursive_dump((pagetable_t)child_pa, current_depth + 1);
            }
        }
    }
}

This function is triggered during the first process initialization within exec.c:

if (current_proc->pid == 1) {
    dump_pagetable(current_proc->pagetable);
}

Per-Process Kernel Page Tables

By default, all processes share a single global kernel page table when executing in kernel mode. The goal here is to allocate a distinct kernel page table for every process, laying the groundwork for direct user memory access within the kernel.

Process Control Block Modification

A new field must be introduced into the proc structure to hold the process-specific kernel page table pointer:

pagetable_t proc_kernel_pt;  // Individual kernel page table

Initializing the Process Kernel Page Table

We create a create_process_kernel_pt function mirroring the global kvminit. It allocates a root page table page and applies identity mappings for kernel text, data, UART, VIRTIO, PLIC, and the trampoline. Crucially, the CLINT mapping is omitted here to prevent virtual address clashes when user mappings are later inserted into the low-memory region. A specialized mapping helper map_process_kernel accepts the specific page table as an argument instead of defaulting to the global table.

pagetable_t create_process_kernel_pt() {
    pagetable_t kpt = (pagetable_t)kalloc();
    memset(kpt, 0, PGSIZE);

    map_process_kernel(kpt, UART0, UART0, PGSIZE, PTE_R | PTE_W);
    map_process_kernel(kpt, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
    // CLINT omitted to free low address space for user mappings
    map_process_kernel(kpt, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
    map_process_kernel(kpt, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);
    map_process_kernel(kpt, (uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext, PTE_R | PTE_W);
    map_process_kernel(kpt, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

    return kpt;
}

During process allocation in allocproc, this new table is instantiated, and a kernel stack is mapped exclusively into it. Since the process owns its kernel page table, the stack can consistently reside at KSTACK(0), eliminating the need for varying offsets based on process indices.

current_proc->proc_kernel_pt = create_process_kernel_pt();
if (!current_proc->proc_kernel_pt) {
    cleanup_process(current_proc);
    release(¤t_proc->lock);
    return 0;
}

char *stack_phys = kalloc();
if (!stack_phys) panic("kalloc");
uint64 stack_virt = KSTACK(0);
map_process_kernel(current_proc->proc_kernel_pt, stack_virt, (uint64)stack_phys, PGSIZE, PTE_R | PTE_W);
current_proc->kstack = stack_virt;

The previous global kernel stack allocation logic within procinit must be removed to avoid double mapping.

Scheduler Integration

When the scheduler dispatches a process, it must load that process's kernel page table into the satp register. Upon context switching back to the scheduler loop, the global kernel page table is restored.

void activate_process_kernel_pt(pagetable_t kpt) {
    w_satp(MAKE_SATP(kpt));
    sfence_vma();
}

// Inside scheduler loop:
if (p->state == RUNNABLE) {
    activate_process_kernel_pt(p->proc_kernel_pt);
    p->state = RUNNING;
    cpu->proc = p;
    swtch(&cpu->scheduler_context, &p->execution_context);
    kvminithart(); // Restore global kernel page table
    cpu->proc = 0;
}

Resource Deallocation

When a process terminates, its kernel stack must be unmapped and its physical memory freed. However, the core kernel mappings (text, data, devices) must only be unmapped from the page table structure without freeing the underlying physical memory, as it is shared globally.

A custom recursive deallocator destroy_pt_structure clears all valid PTEs and frees the page table pages themselves, skipping physical memory deallocation for leaf nodes.

void destroy_pt_structure(pagetable_t pt) {
    for (int idx = 0; idx < 512; idx++) {
        pte_t entry = pt[idx];
        if (entry & PTE_V) {
            pt[idx] = 0;
            if ((entry & (PTE_R | PTE_W | PTE_X)) == 0) {
                uint64 child_pa = PTE2PA(entry);
                destroy_pt_structure((pagetable_t)child_pa);
            }
        }
    }
    kfree((void *)pt);
}

Before calling this, the kernel stack is explicitly unmapped with physical deallocation enabled:

if (p->kstack) {
    uvmunmap(p->proc_kernel_pt, p->kstack, 1, 1);
}
p->kstack = 0;
if (p->proc_kernel_pt) {
    destroy_pt_structure(p->proc_kernel_pt);
}
p->proc_kernel_pt = 0;

Address Translation Fix

The kvmpa function, which translates kernel virtual addresses to physical addresses for disk operations, historically relied on the global kernel page table. It must be updated to accept the current process's kernel page table to function correctly under the new per-process regime.

uint64 kvmpa(pagetable_t active_kpt, uint64 virt_addr) {
    uint64 offset = virt_addr % PGSIZE;
    pte_t *pte_ptr = walk(active_kpt, virt_addr, 0);
    if (!pte_ptr || (*pte_ptr & PTE_V) == 0) panic("kvmpa failure");
    return PTE2PA(*pte_ptr) + offset;
}

The caller in virtio_disk_rw is similarly updated to pass myproc()->proc_kernel_pt.

Accelerating copyin and copyinstr via Hardware Traversal

With individual kernel page tables established, user virtual address mappings can be duplicated into the kernel space. This allows the CPU's hardware walker to resolve user pointers directly during copyin and copyinstr, bypassing the slow software walk function.

Replacing the Copy Functions

The core logic of copyin and copyinstr is replaced by their hardware-optimized counterparts, which perform standard memory copies relying on the now-complete kernel page table mappings.

int copyin(pagetable_t user_pt, char *dest, uint64 src_virt, uint64 length) {
    return copyin_new(user_pt, dest, src_virt, length);
}

int copyinstr(pagetable_t user_pt, char *dest, uint64 src_virt, uint64 max_len) {
    return copyinstr_new(user_pt, dest, src_virt, max_len);
}

Synchronizing User Mappings to Kernel Space

A new function sync_user_mappings_to_kernel iterates through user page table entries and creates identical mappings in the process's kernel page table, pointing to the same physical pages. To prevent the kernel from rejecting user-accessible pages, the PTE_U flag is stripped from the kernel mappings.

int sync_user_mappings_to_kernel(pagetable_t user_pt, pagetable_t kpt, uint64 start, uint64 size) {
    pte_t *pte_entry;
    uint64 phys_addr, virt_addr;
    uint access_flags;

    uint64 aligned_start = PGROUNDUP(start);
    for (virt_addr = aligned_start; virt_addr < start + size; virt_addr += PGSIZE) {
        if ((pte_entry = walk(user_pt, virt_addr, 0)) == 0)
            panic("sync_user_mappings: missing pte");
        if ((*pte_entry & PTE_V) == 0)
            panic("sync_user_mappings: invalid page");
        
        phys_addr = PTE2PA(*pte_entry);
        access_flags = PTE_FLAGS(*pte_entry) & ~PTE_U; // Clear user-mode flag
        
        if (mappages(kpt, virt_addr, PGSIZE, phys_addr, access_flags) != 0) {
            uvmunmap(kpt, aligned_start, (virt_addr - aligned_start) / PGSIZE, 0);
            return -1;
        }
    }
    return 0;
}

Integrating Mapping Synchronization

Every instance where user memory is modified must trigger this synchronization:

  • Process Creation: In fork, after copying user memory to the child, synchronize the child's user mappings to its kernel page table.
  • Program Execution: In exec, the old user mappings in the kernel page table must be unmapped (without freeing physical memory), followed by synchronizing the new user image mappings.
  • Memory Growth: In growproc, positive growth requires uvmalloc followed by synchronizing the newly allocated pages. Negative growth requires uvmdealloc for the user table, and a custom unmap_kernel_only for the kernel table to remove mappings without freeing the underlying shared physical memory. The total process memory size must not exceed the PLIC boundary to avoid clashing with kernel low-memory mappings.
  • Initial Process: In userinit, after setting up the first user process, its single page is synchronized to its kernel page table.

The unmap_kernel_only helper mirrors uvmdealloc but passes a 0 to the physical memory free parameter:

uint64 unmap_kernel_only(pagetable_t kpt, uint64 old_limit, uint64 new_limit) {
    if (new_limit >= old_limit) return old_limit;
    if (PGROUNDUP(new_limit) < PGROUNDUP(old_limit)) {
        int num_pages = (PGROUNDUP(old_limit) - PGROUNDUP(new_limit)) / PGSIZE;
        uvmunmap(kpt, PGROUNDUP(new_limit), num_pages, 0); // Do not free physical memory
    }
    return new_limit;
}

Tags: RISC-V operating systems virtual memory page tables xv6

Posted on Mon, 10 Aug 2026 16:12:26 +0000 by evilMind