开机之后,QEMU 模拟器启动时,每个 CPU 核心都会跳转到 0x80000000
所有核心执行相同的启动代码,但通过 mhartid 区分不同的核心。

内存布局:
stack0 基地址: 0x8000xxxx
CPU 0: sp = stack0 + 4096 (使用 0-4095 字节)
CPU 1: sp = stack0 + 8192 (使用 4096-8191 字节)
CPU 2: sp = stack0 + 12288 (使用 8192-12287 字节)
CPU 3: sp = stack0 + 16384 (使用 12288-16383 字节)

不同的cpu的栈地址也是不同的。不同的cpu会共享内存,但是各自的寄存器在物理上是完全分开的,所以不用担心竞争问题。

# 设置 C 语言的栈
la sp, stack0          # 将 stack0 的地址加载到栈指针 sp
li a0, 1024*4          # 每个 CPU 的栈大小:4096 字节
csrr a1, mhartid       # 读取当前 CPU 的硬件线程 ID(hart ID)
addi a1, a1, 1         # hartid + 1(因为 hartid 从 0 开始)
mul a0, a0, a1         # 计算偏移量:4096 * (hartid + 1)
add sp, sp, a0         # sp = stack0 + 偏移量

call start             # 跳转到 start.c 中的 start() 函数
spin:
    j spin             # 如果 start() 返回,则无限循环

stack0和mscratch0都在.bss段,操作系统一开始是按照实际的物理地址加载的。
这两个数据结构在启动初期都会使用。具体在start.c代码中使用。

# kernel.ld 中的内存布局
SECTIONS {
    . = 0x80000000;
    
    .text : {
        *(.text)
    }
    
    .rodata : {
        *(.rodata)
    }
    
    .data : {
        *(.data)
    }
    
    .bss : {
        *(.bss)
    }
}

在start函数中,主要是对一些中断的处理。设置定时器中断,并且设置最后切换到S模式。

// 包含必要的头文件
#include "types.h"        // 数据类型定义(uint64, int等)
#include "param.h"        // 系统参数(NCPU, NPROC等)
#include "memlayout.h"    // 内存布局定义(CLINT地址等)
#include "riscv.h"        // RISC-V寄存器操作和CSR定义
#include "defs.h"         // 函数声明

// 函数声明
void main();              // 内核主函数,在main.c中定义
void timerinit();         // 定时器初始化函数(本文件定义)
// 函数声明
void main();              // 内核主函数,在main.c中定义
void timerinit();         // 定时器初始化函数(本文件定义)

// ================================================================
// main() 函数 - 内核初始化总入口
// ================================================================
// 在 start() 函数执行 mret 指令后,CPU 从机器模式(M-mode)切换到
// 监督模式(S-mode),并跳转到 main() 函数开始执行。
// main() 是内核初始化的核心,负责设置虚拟内存、初始化各种子系统、
// 创建第一个用户进程,并最终进入调度循环。
//
// 主要执行流程:
// 1. 初始化控制台,为后续打印调试信息做准备
// 2. 打印欢迎 banner,确认内核已正常启动
// 3. 初始化物理页分配器,管理可用物理内存
// 4. 创建内核页表,建立虚拟地址到物理地址的映射
// 5. 启用分页(虚拟内存),CPU 开始使用虚拟地址
// 6. 初始化陷阱向量表,设置中断/异常处理入口
// 7. 初始化进程表、文件系统、设备驱动等子系统
// 8. 创建第一个用户进程(initcode)
// 9. 当前 CPU 开始运行调度器,选择进程执行
// 10. 其他 CPU 核心(如果存在)启动后也进入调度器
//
// 注意:main() 在监督模式(S-mode)下运行,这是操作系统内核的
// 主要执行模式,拥有访问硬件和内存管理的权限,但比机器模式限制更多。


// entry.S 需要每个CPU一个栈
// __attribute__ ((aligned (16))) 确保16字节对齐(RISC-V栈对齐要求)
// stack0: 启动栈,所有CPU共享这个数组,但每个CPU使用不同区域
// 总大小: 4096 * NCPU 字节
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

// 每个CPU的scratch区域,用于定时器中断处理
// mscratch0: 每个CPU 32个uint64的临时存储空间
// 用于在定时器中断处理中保存寄存器状态
uint64 mscratch0[NCPU * 32];

// 声明在kernelvec.S中定义的机器模式定时器中断处理函数
extern void timervec();

// entry.S在机器模式下跳转到此函数,使用stack0作为栈
// 此函数运行在机器模式(M-mode),最高特权级
void
start()
{
  // ============================================================
  // 第1步:设置返回特权级为监督模式(S-mode)
  // ============================================================
  // 读取mstatus(机器状态寄存器)
  unsigned long x = r_mstatus();
  
  // 清除MPP(Machine Previous Privilege)字段
  // MPP字段占2位,记录执行mret后要切换到的模式
  // MSTATUS_MPP_MASK = 0x00001800(bit 11-12)
  x &= ~MSTATUS_MPP_MASK;
  
  // 设置MPP = S(监督模式)
  // MSTATUS_MPP_S = 0x00000800(bit 11 = 1, bit 12 = 0)
  // 这样执行mret后会切换到S-mode
  x |= MSTATUS_MPP_S;
  
  // 写回mstatus寄存器
  w_mstatus(x);

  // ============================================================
  // 第2步:设置mret后的跳转地址
  // ============================================================
  // mepc(Machine Exception Program Counter)寄存器
  // 当执行mret指令时,CPU会跳转到mepc指向的地址
  // 这里设置为main函数的地址,所以mret后会执行main()
  // 需要gcc -mcmodel=medany编译选项支持大地址跳转
  w_mepc((uint64)main);

  // ============================================================
  // 第3步:禁用分页(虚拟内存)
  // ============================================================
  // satp(Supervisor Address Translation and Protection)寄存器
  // 控制虚拟地址转换(页表)
  // 设为0表示禁用分页,所有地址都是物理地址
  // 后续在main()中会启用分页
  w_satp(0);

  // ============================================================
  // 第4步:委派中断和异常到监督模式(S-mode)
  // ============================================================
  // medeleg(Machine Exception Delegation)寄存器
  // 决定哪些异常委派给S-mode处理
  // 0xffff = 所有16种异常都委派(包括指令页错误、加载页错误等)
  // 如果不委派,异常会在M-mode处理
  w_medeleg(0xffff);
  
  // mideleg(Machine Interrupt Delegation)寄存器
  // 决定哪些中断委派给S-mode处理
  // 0xffff = 所有中断都委派(外部中断、定时器中断、软件中断)
  // 这样中断在S-mode处理,而不是M-mode
  w_mideleg(0xffff);
  
  // sie(Supervisor Interrupt Enable)寄存器
  // 启用S-mode下的各种中断
  // r_sie() 读取当前sie的值,在原有基础上添加:
  // SIE_SEIE: 外部中断使能(External Interrupt)
  //   - 用于设备I/O(磁盘、网卡、键盘等)
  // SIE_STIE: 定时器中断使能(Timer Interrupt)
  //   - 用于时钟中断、进程调度
  // SIE_SSIE: 软件中断使能(Software Interrupt)
  //   - 用于核间通信(IPI,Inter-Processor Interrupt)
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // ============================================================
  // 第5步:初始化定时器(时钟中断)
  // ============================================================
  // timerinit()会设置:
  // 1. 定时器间隔(约1/10秒)
  // 2. scratch区域(用于中断处理)
  // 3. M-mode中断向量
  // 4. 启用M-mode中断
  timerinit();

  // ============================================================
  // 第6步:保存CPU ID到tp寄存器
  // ============================================================
  // mhartid(Machine Hart ID)寄存器
  // 返回当前CPU核心的硬件线程ID(0, 1, 2, ...)
  int id = r_mhartid();
  
  // tp(Thread Pointer)寄存器
  // 将hartid保存到tp寄存器,供内核其他部分使用
  // cpuid()函数会读取tp寄存器获取当前CPU编号
  // 这样在多核系统中,每个CPU可以知道自己的ID
  w_tp(id);

  // ============================================================
  // 第7步:切换到监督模式并跳转到main()
  // ============================================================
  // mret(Machine Return)指令
  // 执行特权模式切换:
  // 1. 根据mstatus.MPP切换到指定模式(我们设置为S-mode)
  // 2. 跳转到mepc指向的地址(我们设置为main())
  // 3. 保存之前的模式到mstatus.MPIE
  // 执行后,CPU在S-mode运行main()函数
  asm volatile("mret");
  
  // 注意:mret后不会返回到这里,所以后面的代码不会执行
}

// ================================================================
// 定时器初始化函数
// 设置机器模式定时器中断
// 中断在M-mode触发,然后转发到S-mode处理
// ================================================================
void
timerinit()
{
  // ============================================================
  // 第1步:获取当前CPU ID
  // ============================================================
  // 每个CPU有自己的定时器,独立触发
  int id = r_mhartid();

  // ============================================================
  // 第2步:设置定时器间隔
  // ============================================================
  // 中断间隔(周期数)
  // 在QEMU中,1000000个周期约等于1/10秒
  int interval = 1000000;
  
  // CLINT(Core Local Interruptor)核心本地中断控制器
  // CLINT_MTIME: 64位计数器,不断递增
  // CLINT_MTIMECMP(id): 每个CPU的比较寄存器
  
  // 设置下次中断时间 = 当前时间 + 间隔
  // 当 MTIME >= MTIMECMP 时触发定时器中断
  // 这是内存映射I/O,所以用指针直接写入
  *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;

  // ============================================================
  // 第3步:准备scratch区域(中断处理用的临时存储)
  // ============================================================
  // mscratch0数组:每个CPU 32个uint64
  // 计算当前CPU的scratch基地址
  uint64 *scratch = &mscratch0[32 * id];
  
  // scratch布局(数组索引):
  // scratch[0..3] : 保留给timervec保存寄存器(a0-a3)
  // scratch[4]    : 保存CLINT MTIMECMP寄存器的地址
  // scratch[5]    : 保存定时器间隔(用于重新设置)
  
  // 保存MTIMECMP地址
  // 这样在中断处理中可以直接访问比较寄存器
  scratch[4] = CLINT_MTIMECMP(id);
  
  // 保存间隔值
  // 中断处理时会用这个值重新设置下一次中断
  scratch[5] = interval;
  
  // 将scratch地址写入mscratch寄存器
  // mscratch(Machine Scratch)寄存器
  // 在M-mode中断处理中,可以通过它获取临时存储区
  // 这样中断处理函数就可以使用这些数据
  w_mscratch((uint64)scratch);

  // ============================================================
  // 第4步:设置机器模式中断向量
  // ============================================================
  // mtvec(Machine Trap Vector)寄存器
  // 设置M-mode的中断/异常处理入口地址
  // 当M-mode发生中断或异常时,CPU跳转到mtvec指向的地址
  // timervec在kernelvec.S中实现(汇编代码)
  // 它会保存寄存器,然后调用C函数处理中断
  w_mtvec((uint64)timervec);

  // ============================================================
  // 第5步:启用机器模式中断(全局)
  // ============================================================
  // 读取当前mstatus,添加MIE位
  // MSTATUS_MIE(Machine Interrupt Enable)
  // 这是全局中断使能位:
  // - 0: 所有M-mode中断被屏蔽
  // - 1: 允许M-mode中断
  // 相当于"总开关"
  w_mstatus(r_mstatus() | MSTATUS_MIE);

  // ============================================================
  // 第6步:启用机器模式定时器中断(具体类型)
  // ============================================================
  // mie(Machine Interrupt Enable)寄存器
  // 控制具体类型的中断使能
  // MIE_MTIE(Machine Timer Interrupt Enable)位
  // - 0: 定时器中断被屏蔽
  // - 1: 允许定时器中断
  // 
  // 现在中断路径完全打开:
  // 全局使能(MSTATUS_MIE=1) + 具体类型(MIE_MTIE=1)
  // 定时器到期就会触发中断,跳转到timervec处理
  w_mie(r_mie() | MIE_MTIE);
}

// ================================================================
// 执行流程总结:
// ================================================================
// 1. QEMU启动,所有CPU跳转到0x80000000(entry.S)
// 2. entry.S设置栈,调用start()
// 3. start()在M-mode执行:
//    a. 设置mret返回模式为S-mode
//    b. 设置mret返回地址为main()
//    c. 禁用分页
//    d. 委派中断/异常到S-mode
//    e. 初始化定时器(设置间隔、scratch、中断向量)
//    f. 保存hartid到tp寄存器
//    g. 执行mret切换到S-mode
// 4. 在S-mode执行main()
// 5. 定时器中断发生:M-mode → timervec → S-mode → devintr()
// ================================================================

下面是一些宏定义。

// memlayout.h 中定义
#define CLINT 0x2000000L
#define CLINT_MTIME (CLINT + 0xBFF8)     // 当前时间计数器
#define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))  // 每个CPU的比较器

kernal.S里的函数:
timevec也是在这个文件里面,时钟中断向量的地址在之前已经被写入了w_mtvec((uint64)timervec);这个寄存器;

#
# 当在监督模式(S-mode)下发生中断和异常时,会跳转到此处
# 这是内核的中断/异常处理入口点
#
# 流程:保存所有寄存器 → 调用C语言处理函数 kerneltrap() → 恢复寄存器 → 返回
#

# 声明全局符号,使C代码可以引用
.globl kerneltrap      # C函数,在trap.c中定义
.globl kernelvec       # 本汇编入口点
.align 4               # 4字节对齐(16字节对齐)

# ================================================================
# 内核中断/异常向量(Supervisor模式)
# ================================================================
kernelvec:
        # 为保存寄存器在栈上腾出空间
        # 需要保存32个寄存器,每个8字节(64位)
        # 32 * 8 = 256 字节
        addi sp, sp, -256

        # ============================================================
        # 保存所有寄存器到栈中
        # ============================================================
        # sd = store doubleword(存储64位数据)
        # 格式: sd 源寄存器, 偏移量(栈指针)
        
        # ra (Return Address) - 返回地址寄存器
        # 保存调用者的返回地址,这样中断返回时可以继续执行
        sd ra, 0(sp)
        
        # sp (Stack Pointer) - 栈指针
        # 保存当前的栈指针,虽然不太需要,但为了完整性保存
        sd sp, 8(sp)
        
        # gp (Global Pointer) - 全局指针
        # 用于访问全局变量,保存以便恢复
        sd gp, 16(sp)
        
        # tp (Thread Pointer) - 线程指针
        # 存储当前CPU的hartid,在中断返回时恢复
        sd tp, 24(sp)
        
        # t0-t2 - 临时寄存器(caller-saved)
        # 函数调用时不需要保存,但中断可能随时发生,需要保存
        sd t0, 32(sp)
        sd t1, 40(sp)
        sd t2, 48(sp)
        
        # s0-s1 - 保存寄存器(callee-saved)
        # 函数调用时需要保存,但中断处理也需要保存
        sd s0, 56(sp)
        sd s1, 64(sp)
        
        # a0-a7 - 参数寄存器(caller-saved)
        # 传递函数参数,中断时必须保存
        sd a0, 72(sp)
        sd a1, 80(sp)
        sd a2, 88(sp)
        sd a3, 96(sp)
        sd a4, 104(sp)
        sd a5, 112(sp)
        sd a6, 120(sp)
        sd a7, 128(sp)
        
        # s2-s11 - 保存寄存器(callee-saved)
        # 函数调用时需要保存,中断处理也需要保存
        sd s2, 136(sp)
        sd s3, 144(sp)
        sd s4, 152(sp)
        sd s5, 160(sp)
        sd s6, 168(sp)
        sd s7, 176(sp)
        sd s8, 184(sp)
        sd s9, 192(sp)
        sd s10, 200(sp)
        sd s11, 208(sp)
        
        # t3-t6 - 临时寄存器(caller-saved)
        sd t3, 216(sp)
        sd t4, 224(sp)
        sd t5, 232(sp)
        sd t6, 240(sp)

        # ============================================================
        # 调用C语言的中断处理函数
        # ============================================================
        # kerneltrap 在 trap.c 中定义
        # 它负责:
        # 1. 判断中断类型(定时器、外部设备、软件中断等)
        # 2. 处理系统调用(如果是系统调用异常)
        # 3. 处理页错误(如果是页错误异常)
        # 4. 时钟中断时调用 yield() 进行进程调度
        call kerneltrap

        # ============================================================
        # 恢复之前保存的寄存器(从栈中加载)
        # ============================================================
        # ld = load doubleword(加载64位数据)
        # 格式: ld 目标寄存器, 偏移量(栈指针)
        
        # 恢复返回地址
        ld ra, 0(sp)
        
        # 恢复栈指针
        # 注意:可能因为进程切换,sp可能已经改变
        # 但我们保存的是中断发生时的sp,需要恢复
        ld sp, 8(sp)
        
        # 恢复全局指针
        ld gp, 16(sp)
        
        # 恢复线程指针
        # 注意:这里注释掉了,因为可能发生了CPU迁移
        # 如果发生了CPU迁移,tp应该更新为新CPU的hartid
        # 而不是恢复旧值
        # ld tp, 24(sp)    # 不恢复,防止CPU迁移问题
        
        # 恢复临时和保存寄存器
        ld t0, 32(sp)
        ld t1, 40(sp)
        ld t2, 48(sp)
        ld s0, 56(sp)
        ld s1, 64(sp)
        ld a0, 72(sp)
        ld a1, 80(sp)
        ld a2, 88(sp)
        ld a3, 96(sp)
        ld a4, 104(sp)
        ld a5, 112(sp)
        ld a6, 120(sp)
        ld a7, 128(sp)
        ld s2, 136(sp)
        ld s3, 144(sp)
        ld s4, 152(sp)
        ld s5, 160(sp)
        ld s6, 168(sp)
        ld s7, 176(sp)
        ld s8, 184(sp)
        ld s9, 192(sp)
        ld s10, 200(sp)
        ld s11, 208(sp)
        ld t3, 216(sp)
        ld t4, 224(sp)
        ld t5, 232(sp)
        ld t6, 240(sp)

        # 释放栈空间(恢复原来的栈指针)
        # 注意:这里恢复的是保存的sp + 256
        # 因为我们之前保存了sp,所以sp = 原来的sp - 256
        # 执行这个指令后,sp = 原来的sp
        addi sp, sp, 256

        # ============================================================
        # 返回到中断发生前正在执行的代码
        # ============================================================
        # sret(Supervisor Return)指令
        # 从S-mode返回到之前的特权级:
        # 1. 根据sstatus.SPP恢复到之前的特权级(U-mode或S-mode)
        # 2. 跳转到sepc指向的地址
        # 3. 恢复中断使能状态
        # 这样CPU就继续执行被中断的代码
        sret


# ================================================================
# 机器模式定时器中断处理
# ================================================================
# 这个函数在M-mode处理定时器中断,然后将它转换为S-mode软件中断
# 这样S-mode的内核就可以处理定时器中断了

.globl timervec
.align 4

timervec:
        # ============================================================
        # start.c 已经设置了 mscratch 指向的内存区域:
        # mscratch 指向 mscratch0[32 * hartid]
        # 布局如下:
        # scratch[0]   : 保存 a0 寄存器
        # scratch[8]   : 保存 a1 寄存器
        # scratch[16]  : 保存 a3 寄存器
        # scratch[32]  : CLINT_MTIMECMP(hart) 的地址
        # scratch[40]  : 定时器中断间隔(interval)
        # ============================================================
        
        # ============================================================
        # 交换 a0 和 mscratch 的值
        # ============================================================
        # csrrw(CSR Read Write)指令
        # 格式: csrrw rd, csr, rs
        # 功能: rd = csr, csr = rs
        # 这里: a0 = mscratch, mscratch = a0
        # 这样 a0 就指向了 scratch 区域,而 mscratch 保存了原来的 a0
        csrrw a0, mscratch, a0
        
        # ============================================================
        # 保存寄存器到 scratch 区域
        # ============================================================
        # 将 a1, a2, a3 保存到 scratch 区域
        # 因为这些寄存器之后会被使用,需要保存原来的值
        # 注意:a0 已经保存到 mscratch 中了
        sd a1, 0(a0)     # a1 → scratch[0]
        sd a2, 8(a0)     # a2 → scratch[8]
        sd a3, 16(a0)    # a3 → scratch[16]
        
        # ============================================================
        # 安排下一次定时器中断
        # ============================================================
        # 方法:将 interval 加到 MTIMECMP 寄存器上
        # 当 MTIME >= MTIMECMP 时触发中断
        # 所以每次中断后,将 MTIMECMP += interval,实现周期性中断
        
        # 加载 MTIMECMP 寄存器的地址
        # scratch[32] 存储的是 CLINT_MTIMECMP(hart) 的地址
        ld a1, 32(a0)    # a1 = scratch[32] = &MTIMECMP
        
        # 加载中断间隔
        # scratch[40] 存储的是 interval(1000000周期)
        ld a2, 40(a0)    # a2 = scratch[40] = interval
        
        # 读取当前 MTIMECMP 的值
        # a1 是 MTIMECMP 的地址,*a1 就是当前值
        ld a3, 0(a1)     # a3 = *MTIMECMP(当前比较值)
        
        # 计算下一次中断时间:当前值 + 间隔
        add a3, a3, a2   # a3 = a3 + a2(新的比较值)
        
        # 写回 MTIMECMP
        sd a3, 0(a1)     # *MTIMECMP = a3(设置下次中断时间)
        
        # ============================================================
        # 触发一个监督模式软件中断
        # ============================================================
        # 由于定时器中断被委派到 S-mode,但在 M-mode 处理
        # 为了通知 S-mode,我们触发一个软件中断
        # S-mode 的软件中断处理函数会处理定时器事件
        
        # sip(Supervisor Interrupt Pending)寄存器
        # 位1是 SSIP(Supervisor Software Interrupt Pending)
        # 设置为1表示有一个软件中断在等待处理
        li a1, 2         # a1 = 2(二进制 10,即第1位)
        csrw sip, a1     # sip = 2,设置SSIP位
        
        # ============================================================
        # 恢复之前保存的寄存器
        # ============================================================
        # 从 scratch 区域恢复 a3, a2, a1
        ld a3, 16(a0)    # 恢复 a3
        ld a2, 8(a0)     # 恢复 a2
        ld a1, 0(a0)     # 恢复 a1
        
        # 恢复 a0 和 mscratch
        # csrrw a0, mscratch, a0
        # 再次交换:a0 = mscratch(保存的原a0),mscratch = a0(当前的a0)
        # 这样就完全恢复了 a0 和 mscratch
        csrrw a0, mscratch, a0
        
        # ============================================================
        # 从机器模式返回
        # ============================================================
        # mret(Machine Return)指令
        # 从M-mode返回到之前的特权级:
        # 1. 根据mstatus.MPP恢复到之前的模式(S-mode)
        # 2. 跳转到mepc指向的地址(被中断的代码地址)
        # 3. 恢复中断使能状态
        mret


# ================================================================
# 中断处理流程总结:
# ================================================================
# 
# 1. 定时器到期(MTIME >= MTIMECMP)
#    ↓
# 2. 硬件触发 M-mode 定时器中断
#    ↓
# 3. 跳转到 mtvec(即 timervec)
#    ↓
# 4. timervec 执行:
#    a. 保存 a0-a3 到 scratch 区域
#    b. 设置下一次中断:MTIMECMP += interval
#    c. 触发 S-mode 软件中断(设置 sip.SSIP = 1
#    d. 恢复 a0-a3
#    e. mret 返回到 S-mode
#    ↓
# 5. 由于 sip.SSIP = 1,S-mode 检测到软件中断
#    ↓
# 6. 跳转到 stvec(即 kernelvec)
#    ↓
# 7. kernelvec 执行:
#    a. 保存所有寄存器到栈
#    b. 调用 kerneltrap()(C语言函数)
#       - 判断是软件中断(来自定时器)
#       - 调用 devintr() 处理
#       - 可能调用 yield() 进行进程调度
#    c. 恢复所有寄存器
#    d. sret 返回被中断的代码
#    ↓
# 8. 程序继续执行
#
# ================================================================
# 为什么要这样设计?
# ================================================================
# 
# 1. 分离关注点:
#    - M-mode 只处理硬件级别的定时器重新配置
#    - S-mode 处理操作系统级别的调度逻辑
# 
# 2. 安全性:
#    - 操作系统在 S-mode 运行,不能直接访问 M-mode 的 CSR
#    - 通过委派机制,确保安全
# 
# 3. 可移植性:
#    - 定时器硬件细节在 M-mode 处理
#    - S-mode 代码不依赖具体硬件
# 
# 4. 效率:
#    - M-mode 处理很轻量,只做必要的硬件操作
#    - S-mode 处理复杂的操作系统逻辑
# 
# ================================================================

为什么注释掉 ld tp, 24(sp)?

not this, in case we moved CPUs: ld tp, 24(sp)

在多核系统中,中断处理过程中可能发生CPU迁移(一个进程从一个CPU移到另一个CPU)。如果恢复旧的 tp 值,会导致 CPU ID 错误,所以需要从当前CPU重新获取 hartid。
这个设计体现了 xv6 对多核系统的精心考虑,将硬件中断处理和操作系统调度清晰地分离开来。
并且定时器中断在M和S模式下都有处理函数,不过职责不同:

┌────────────────────────────────────────────────────────────────────┐
│ 定时器硬件 (CLINT)                                               │
│ MTIME 计数器不断递增                                            │
│ 当 MTIME >= MTIMECMP 时触发中断                                │
└────────────────────────┬───────────────────────────────────────┘
                         ↓
┌────────────────────────────────────────────────────────────────────┐
│ M-mode 中断处理 (timervec)                                      │
│ 1. 保存 a0-a3 到 scratch                                        │
│ 2. MTIMECMP += interval (设置下次中断)                         │
│ 3. 设置 sip.SSIP = 1 (触发软件中断)                            │
│ 4. 恢复 a0-a3                                                  │
│ 5. mret 返回                                                   │
│                                                                  │
│ ⏱️ 执行时间: ~100-200 CPU周期                                   │
└────────────────────────┬───────────────────────────────────────┘
                         ↓
┌────────────────────────────────────────────────────────────────────┐
│ S-mode 软件中断处理 (kernelvec)                                 │
│ 1. 保存所有 32 个寄存器到栈                                     │
│ 2. call kerneltrap()                                           │
│    - 识别为定时器中断                                          │
│    - clockintr() (更新时间)                                    │
│    - yield() (进程调度)                                        │
│ 3. 恢复所有寄存器                                              │
│ 4. sret 返回                                                   │
│                                                                  │
│ ⏱️ 执行时间: ~1000-2000 CPU周期                                 │
└────────────────────────────────────────────────────────────────────┘

这里插入一下kernaltrap函数,因为内核中断向量会跳转到这个地方来:

void 
kerneltrap()
{
  // 用于存储设备中断号,0表示不是设备中断
  int which_dev = 0;
  
  // 保存发生陷阱时的关键CPU状态寄存器
  uint64 sepc = r_sepc();      // 保存异常返回地址(发生陷阱时的PC值)
  uint64 sstatus = r_sstatus(); // 保存主管态状态寄存器
  uint64 scause = r_scause();   // 保存异常/中断原因代码
  
  // 检查1:确保陷阱确实来自监督模式(S-mode)
  // SSTATUS_SPP位指示进入陷阱前CPU处于S模式还是U模式
  // 如果是用户模式进入,则内核态陷阱处理程序不应该被调用
  if((sstatus & SSTATUS_SPP) == 0)
    panic("kerneltrap: not from supervisor mode");
  
  // 检查2:确保在进入陷阱处理程序时中断已被禁用
  // intr_get()检查sstatus.SIE位,如果为1表示中断使能
  // 内核陷阱处理期间必须禁用中断以避免嵌套
  if(intr_get() != 0)
    panic("kerneltrap: interrupts enabled");

  // 调用devintr()检查是否是外部设备中断或软件中断
  // 返回值:0=不是设备中断, 1=外部设备中断, 2=定时器中断
  if((which_dev = devintr()) == 0){
    // 如果不是设备中断,则是其他异常(如非法指令、缺页等)
    // 打印详细信息用于调试
    printf("scause %p\n", scause);        // 异常原因
    printf("sepc=%p stval=%p\n", r_sepc(), r_stval()); // 异常地址和附加信息
    panic("kerneltrap");                  // 内核无法处理,触发恐慌
  }

  // 如果是定时器中断(which_dev == 2)且当前进程存在且正在运行
  // 则主动让出CPU(实现抢占式调度)
  if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
    yield();  // 调用调度器切换到另一个进程

  // 注意:yield()可能会触发上下文切换,期间可能修改sepc和sstatus
  // 因此需要恢复之前保存的陷阱寄存器值
  // 这样kernelvec.S中的sret指令才能正确返回到被中断的位置
  w_sepc(sepc);      // 恢复异常返回地址
  w_sstatus(sstatus); // 恢复主管态状态(包括中断使能状态等)
}

这里的内核中断处理函数其实特别简单,就处理了两种情况:

外部设备中断流程:
硬件中断 → kerneltrap → devintr() → 具体驱动处理 → 返回kerneltrap
                ↓
          which_dev == 1
                ↓
        不做额外处理,直接恢复执行
        (设备数据已被驱动读取完成)

定时器中断流程:
硬件中断 → kerneltrap → devintr() → 识别为定时器中断
                ↓
          which_dev == 2
                ↓
          yield() → 进程调度
         (时间片用完,切换进程)

最后是跳转到main函数,由于是多核系统,具体时间顺序如图。

时间:所有 CPU 同时进入 main()

CPU 0: if(cpuid()==0) ✅ 开始初始化...
CPU 1: if(cpuid()==0) ❌ while(started==0) {}
CPU 2: if(cpuid()==0) ❌ while(started==0) {}
CPU 3: if(cpuid()==0) ❌ while(started==0) {}

... CPU 0 初始化中 ...

CPU 0: ... userinit() ... started = 1; scheduler();
CPU 1: while(started==0) {} → 继续执行
CPU 2: while(started==0) {} → 继续执行
CPU 3: while(started==0) {} → 继续执行

CPU 1: kvminithart(); trapinithart(); plicinithart(); scheduler();
CPU 2: kvminithart(); trapinithart(); plicinithart(); scheduler();
CPU 3: kvminithart(); trapinithart(); plicinithart(); scheduler();

所有 CPU 都在 scheduler() 中运行进程

main函数就是各种初始化工作:

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

volatile static int started = 0;

// start() jumps here in supervisor mode on all CPUs.
void
main()
{
  if(cpuid() == 0){
    consoleinit();
#if defined(LAB_PGTBL) || defined(LAB_LOCK)
    statsinit();
#endif
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors
    trapinithart();  // install kernel trap vector
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    binit();         // buffer cache
    iinit();         // inode cache
    fileinit();      // file table
    virtio_disk_init(); // emulated hard disk
#ifdef LAB_NET
    pci_init();
    sockinit();
#endif    
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();    // turn on paging
    trapinithart();   // install kernel trap vector
    plicinithart();   // ask PLIC for device interrupts
  }

  scheduler();        
}

先是初始化控制台:

//
// 控制台输入输出,通过 UART(串口)实现
// 读取按行进行(行缓冲)
// 实现特殊输入字符:
//   newline (换行)    -- 行结束
//   control-h (退格)  -- 删除前一个字符
//   control-u (删除行) -- 删除整行
//   control-d (文件结束) -- 表示输入结束
//   control-p (打印进程) -- 打印进程列表
//

#include <stdarg.h>         // 可变参数支持(printf使用)

#include "types.h"          // 数据类型定义
#include "param.h"          // 系统参数
#include "spinlock.h"       // 自旋锁
#include "sleeplock.h"      // 睡眠锁
#include "fs.h"             // 文件系统
#include "file.h"           // 文件结构
#include "memlayout.h"      // 内存布局
#include "riscv.h"          // RISC-V 寄存器操作
#include "defs.h"           // 函数声明
#include "proc.h"           // 进程结构

// 退格键的魔法值(不是ASCII字符)
#define BACKSPACE 0x100
// 将字符转换为控制字符:C('D') = 0x04 (EOT)
#define C(x)  ((x)-'@')  // Control-x

// ================================================================
// 向控制台发送一个字符(用于 printf 和回显)
// ================================================================
void
consputc(int c)
{
  if(c == BACKSPACE){
    // 如果用户输入退格,用空格覆盖(模拟删除效果)
    // 输出:退格 → 空格 → 退格
    // 这样光标会移到左边,用空格覆盖字符,再移回左边
    uartputc_sync('\b');   // 退格(光标左移)
    uartputc_sync(' ');    // 空格(覆盖字符)
    uartputc_sync('\b');   // 退格(光标回到原位置)
  } else {
    // 普通字符直接发送到 UART
    uartputc_sync(c);
  }
}

// ================================================================
// 控制台数据结构
// ================================================================
struct {
  struct spinlock lock;    // 保护控制台缓冲区的自旋锁
  
  // 输入缓冲区(环形缓冲区)
#define INPUT_BUF 128      // 缓冲区大小
  char buf[INPUT_BUF];     // 字符缓冲区
  uint r;                  // 读索引(consoleread 读取位置)
  uint w;                  // 写索引(已处理位置)
  uint e;                  // 编辑索引(输入位置)
} cons;

// ================================================================
// 控制台写入函数(系统调用 write 到控制台设备)
// user_src: 数据来源(用户空间或内核空间)
// src: 数据地址
// n: 要写入的字节数
// ================================================================
int
consolewrite(int user_src, uint64 src, int n)
{
  int i;

  // 获取锁,保护共享缓冲区
  acquire(&cons.lock);
  
  // 逐个字符写入
  for(i = 0; i < n; i++){
    char c;
    // 从用户空间或内核空间复制一个字符
    if(either_copyin(&c, user_src, src+i, 1) == -1)
      break;
    // 通过 UART 发送字符
    uartputc(c);
  }
  
  // 释放锁
  release(&cons.lock);

  // 返回实际写入的字符数
  return i;
}

// ================================================================
// 控制台读取函数(系统调用 read 从控制台设备)
// user_dst: 目标地址空间(用户或内核)
// dst: 目标地址
// n: 要读取的字节数
// 读取一行输入(遇到换行符返回)
// ================================================================
int
consoleread(int user_dst, uint64 dst, int n)
{
  uint target;              // 目标读取字节数(原始请求)
  int c;                    // 当前字符
  char cbuf;                // 字符缓冲区(用于复制)

  target = n;               // 保存原始请求数量
  acquire(&cons.lock);      // 获取锁
  
  while(n > 0){
    // ============================================================
    // 等待输入:如果没有数据可读,进入睡眠
    // ============================================================
    while(cons.r == cons.w){  // 读索引 == 写索引 → 没有新数据
      // 如果当前进程被杀死,返回错误
      if(myproc()->killed){
        release(&cons.lock);
        return -1;
      }
      // 睡眠等待输入
      // 参数1: &cons.r(等待条件地址,用于唤醒)
      // 参数2: &cons.lock(睡眠时释放锁)
      // 当有输入时,consoleintr 会调用 wakeup(&cons.r)
      sleep(&cons.r, &cons.lock);
    }

    // 从缓冲区读取一个字符
    // cons.r % INPUT_BUF 实现环形缓冲区
    c = cons.buf[cons.r++ % INPUT_BUF];

    // ============================================================
    // 处理 Control-D (文件结束)
    // ============================================================
    if(c == C('D')){  // end-of-file
      // 如果已经读取了至少一个字符
      if(n < target){
        // 将 ^D 放回缓冲区,下次调用时返回 0 字节
        // 这符合 Unix 语义:^D 在行首表示 EOF
        cons.r--;
      }
      break;  // 退出读取循环
    }

    // ============================================================
    // 复制字符到用户缓冲区
    // ============================================================
    cbuf = c;
    if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
      break;  // 复制失败

    dst++;      // 目标地址前进
    --n;        // 剩余字节数减1

    // ============================================================
    // 遇到换行符,一行结束,返回
    // ============================================================
    if(c == '\n'){
      break;
    }
  }
  
  release(&cons.lock);  // 释放锁

  // 返回实际读取的字节数
  return target - n;
}

// ================================================================
// 控制台中断处理函数
// 由 uartintr() 在接收到字符时调用
// 处理退格、删除行等编辑功能
// ================================================================
void
consoleintr(int c)
{
  // 获取锁,保护缓冲区
  acquire(&cons.lock);

  // 根据接收到的字符进行处理
  switch(c){
  
  // ============================================================
  // Control-P: 打印进程列表(调试功能)
  // ============================================================
  case C('P'):  // Print process list.
    procdump();  // 在 proc.c 中实现,打印所有进程信息
    break;
  
  // ============================================================
  // Control-U: 删除整行(Kill line)
  // ============================================================
  case C('U'):  // Kill line.
    // 从当前编辑位置回退到行首(上一个换行符)
    while(cons.e != cons.w &&
          cons.buf[(cons.e-1) % INPUT_BUF] != '\n'){
      cons.e--;                // 编辑位置回退
      consputc(BACKSPACE);     // 在屏幕上删除一个字符
    }
    break;
  
  // ============================================================
  // Control-H 或 Delete: 删除前一个字符(Backspace)
  // ============================================================
  case C('H'): // Backspace
  case '\x7f': // DEL 键
    if(cons.e != cons.w){      // 如果有字符可删除
      cons.e--;                // 编辑位置回退
      consputc(BACKSPACE);     // 删除屏幕上的字符
    }
    break;
  
  // ============================================================
  // 普通字符:添加到缓冲区并回显
  // ============================================================
  default:
    // 检查是否有空间(缓冲区未满)
    if(c != 0 && cons.e-cons.r < INPUT_BUF){
      // 将回车转换为换行(Unix风格)
      c = (c == '\r') ? '\n' : c;

      // 回显字符到屏幕(用户能看到自己输入的字符)
      consputc(c);

      // 存储到缓冲区
      cons.buf[cons.e++ % INPUT_BUF] = c;

      // 如果遇到换行、文件结束符,或者缓冲区满
      if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
        // 更新写索引 = 编辑索引
        // 表示有新数据可供读取
        cons.w = cons.e;
        // 唤醒正在等待输入的 consoleread
        wakeup(&cons.r);
      }
    }
    break;
  }
  
  // 释放锁
  release(&cons.lock);
}

// ================================================================
// 控制台初始化函数
// 在系统启动时由 main() 调用
// ================================================================
void
consoleinit(void)
{
  // 初始化控制台的自旋锁
  initlock(&cons.lock, "cons");

  // 初始化 UART(串口)硬件
  // uartinit() 在 uart.c 中实现
  // 它会设置 UART 寄存器,启用接收中断等
  uartinit();

  // 连接 read/write 系统调用到控制台函数
  // devsw 是设备开关表(在 file.c 中定义)
  // CONSOLE 是控制台设备号(在 file.h 中定义)
  // 这样当用户对控制台文件执行 read/write 时,
  // 就会调用 consoleread 和 consolewrite
  devsw[CONSOLE].read = consoleread;
  devsw[CONSOLE].write = consolewrite;
}

// ================================================================
// 控制台工作原理总结
// ================================================================
//
// 1. 输入流程(键盘 → 屏幕):
//    a. 键盘敲击 → UART 硬件接收
//    b. UART 触发中断 → uartintr() 被调用
//    c. uartintr() 读取字符 → 调用 consoleintr(c)
//    d. consoleintr() 处理字符:
//       - 特殊字符(退格、删除行等)→ 编辑处理
//       - 普通字符 → 存入缓冲区 + 回显
//       - 遇到换行 → 唤醒 consoleread
//    e. 用户看到自己的输入(回显)
//
// 2. 输出流程(printf → 屏幕):
//    a. printf() 调用 consputc()
//    b. consputc() 调用 uartputc_sync()
//    c. uartputc_sync() 等待 UART 空闲后发送字符
//    d. 字符通过串口显示在终端上
//
// 3. 用户程序读取输入(read 系统调用):
//    a. 用户调用 read(fd, buf, n)
//    b. 文件系统调用 devsw[CONSOLE].read()
//    c. 即 consoleread()
//    d. 如果有数据,复制到用户空间
//    e. 如果没有数据,睡眠等待(sleep(&cons.r))
//    f. 当输入一行完成后,consoleintr 调用 wakeup(&cons.r)
//    g. consoleread 被唤醒,继续执行
//
// 4. 用户程序写入输出(write 系统调用):
//    a. 用户调用 write(fd, buf, n)
//    b. 文件系统调用 devsw[CONSOLE].write()
//    c. 即 consolewrite()
//    d. 逐个字符调用 uartputc() 输出
//
// ================================================================
// 环形缓冲区工作原理
// ================================================================
//
// 缓冲区状态示例:
//
// 1. 空状态:
//    r = 0, w = 0, e = 0
//    [ ] [ ] [ ] [ ] [ ] ...
//     r/w/e
//
// 2. 用户输入 "hel"(还没按回车):
//    r = 0, w = 0, e = 3
//    [h] [e] [l] [ ] [ ] ...
//     r/w       e
//
// 3. 用户输入换行(完成一行):
//    r = 0, w = 4, e = 4
//    [h] [e] [l] [\n] [ ] ...
//     r         w/e
//
// 4. consoleread 读取 2 个字符:
//    r = 2, w = 4, e = 4
//    [h] [e] [l] [\n] [ ] ...
//         r     w/e
//
// 5. 缓冲区回绕(环形):
//    当索引达到 INPUT_BUF-1 时,下一个字符写到 0
//    使用 mod 运算:buf[cons.r++ % INPUT_BUF]
//
// ================================================================
// 特殊控制字符
// ================================================================
//
// 字符      值      作用
// Control-H  8      退格(删除前一个字符)
// Control-U  21     删除整行
// Control-D  4      文件结束(EOF)
// Control-P  16     打印进程列表(调试)
// \r         13     回车(转换为换行)
// \n         10     换行(行结束)
// DEL        127    删除键(同退格)
//
// ================================================================

最后一个数组函数在file.h里面

// file.h 中定义
#define CONSOLE 1  // 控制台设备号

// devsw 设备开关表
struct devsw {
  int (*read)(int, uint64, int);
  int (*write)(int, uint64, int);
} devsw[NDEV];

// consoleinit 中连接
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].write = consolewrite;

uart.c

//
// 16550a UART(通用异步收发传输器)的低级驱动例程
// UART 是计算机与外部设备(如终端、键盘)通信的串行接口
//

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "proc.h"
#include "defs.h"

// ================================================================
// UART 寄存器定义(内存映射I/O)
// ================================================================
// UART 控制寄存器通过内存映射方式访问
// UART0 是基地址(在 memlayout.h 中定义)
// 这个宏返回指定寄存器的地址
#define Reg(reg) ((volatile unsigned char *)(UART0 + reg))

// UART 控制寄存器偏移量
// 有些寄存器读和写有不同的含义
// 参考文档:http://byterunner.com/16550.html

// ================================================================
// 数据寄存器
// ================================================================
#define RHR 0                 // 接收保持寄存器(读取输入字节)
#define THR 0                 // 发送保持寄存器(写入输出字节)
// 注意:RHR 和 THR 使用相同的地址,读操作访问 RHR,写操作访问 THR

// ================================================================
// 中断使能寄存器 (IER)
// ================================================================
#define IER 1                 // 中断使能寄存器
#define IER_TX_ENABLE (1<<0)  // 发送中断使能(当发送缓冲区空时触发)
#define IER_RX_ENABLE (1<<1)  // 接收中断使能(当收到字符时触发)

// ================================================================
// FIFO 控制寄存器 (FCR) / 中断状态寄存器 (ISR)
// ================================================================
#define FCR 2                 // FIFO 控制寄存器(写操作)
#define FCR_FIFO_ENABLE (1<<0)   // 启用 FIFO(先进先出缓冲区)
#define FCR_FIFO_CLEAR (3<<1)    // 清空两个 FIFO(发送和接收)
#define ISR 2                 // 中断状态寄存器(读操作)

// ================================================================
// 线路控制寄存器 (LCR)
// ================================================================
#define LCR 3                 // 线路控制寄存器
#define LCR_EIGHT_BITS (3<<0) // 8位数据位,1位停止位,无校验
#define LCR_BAUD_LATCH (1<<7) // 特殊模式:设置波特率(访问除数锁存器)

// ================================================================
// 线路状态寄存器 (LSR)
// ================================================================
#define LSR 5                 // 线路状态寄存器
#define LSR_RX_READY (1<<0)   // 接收数据就绪(RHR 中有数据可读)
#define LSR_TX_IDLE (1<<5)    // 发送空闲(THR 可以接收新字符)

// 读写寄存器的便捷宏
#define ReadReg(reg) (*(Reg(reg)))   // 读寄存器
#define WriteReg(reg, v) (*(Reg(reg)) = (v))  // 写寄存器

// ================================================================
// 发送输出缓冲区(环形缓冲区)
// ================================================================
struct spinlock uart_tx_lock;        // 保护发送缓冲区的自旋锁
#define UART_TX_BUF_SIZE 32          // 发送缓冲区大小
char uart_tx_buf[UART_TX_BUF_SIZE];  // 发送缓冲区
int uart_tx_w;   // 写索引:下一个字符写入的位置
int uart_tx_r;   // 读索引:下一个字符发送的位置

// 从 printf.c 导入的全局变量,用于紧急情况
extern volatile int panicked;

// 函数声明
void uartstart();

// ================================================================
// UART 初始化函数
// 在系统启动时由 consoleinit() 调用
// ================================================================
void
uartinit(void)
{
  // ============================================================
  // 第1步:禁用所有中断
  // ============================================================
  // 在配置过程中,先禁用中断,防止在设置过程中触发意外的中断
  WriteReg(IER, 0x00);

  // ============================================================
  // 第2步:设置波特率(通信速度)为 38400
  // ============================================================
  // 进入波特率设置模式(启用除数锁存器访问)
  // LCR_BAUD_LATCH = 0x80
  WriteReg(LCR, LCR_BAUD_LATCH);

  // 设置波特率除数 = 0x0003
  // 对于 16550A UART,波特率 = 时钟频率 / (16 * 除数)
  // 假设时钟频率为 1.8432MHz,除数 3 得到 38400 波特
  WriteReg(0, 0x03);    // LSB(低8位)= 0x03
  WriteReg(1, 0x00);    // MSB(高8位)= 0x00

  // ============================================================
  // 第3步:设置数据格式:8位数据,无校验,1位停止位
  // ============================================================
  // 退出波特率设置模式,设置数据格式
  // LCR_EIGHT_BITS = 0x03
  WriteReg(LCR, LCR_EIGHT_BITS);

  // ============================================================
  // 第4步:启用并清空 FIFO(先入先出缓冲区)
  // ============================================================
  // FIFO 可以提高吞吐量,减少中断频率
  // FCR_FIFO_ENABLE = 0x01, FCR_FIFO_CLEAR = 0x06
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);

  // ============================================================
  // 第5步:启用发送和接收中断
  // ============================================================
  // 现在 UART 配置完成,可以启用中断了
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);

  // ============================================================
  // 第6步:初始化发送缓冲区的自旋锁
  // ============================================================
  initlock(&uart_tx_lock, "uart");
}

// ================================================================
// 向 UART 发送一个字符(带缓冲,可能阻塞)
// 
// 特点:
// - 使用中断驱动(异步)
// - 如果缓冲区满,睡眠等待
// - 适合在进程上下文中使用(如 write() 系统调用)
// - 不能在中断处理函数中调用(可能阻塞)
// ================================================================
void
uartputc(int c)
{
  // 获取发送缓冲区锁
  acquire(&uart_tx_lock);

  // 如果系统处于 panic 状态,无限循环(停止输出)
  if(panicked){
    for(;;)
      ;
  }

  // 循环直到字符被放入缓冲区
  while(1){
    // 检查缓冲区是否已满
    // 环形缓冲区满的条件:(写索引 + 1) % 大小 == 读索引
    if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
      // 缓冲区已满,睡眠等待空间释放
      // 当 uartstart() 发送字符并释放空间时,会唤醒等待的进程
      sleep(&uart_tx_r, &uart_tx_lock);
    } else {
      // 缓冲区有空间,放入字符
      uart_tx_buf[uart_tx_w] = c;
      uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
      
      // 启动发送(如果 UART 空闲,立即发送)
      uartstart();
      
      // 释放锁并返回
      release(&uart_tx_lock);
      return;
    }
  }
}

// ================================================================
// UART 同步发送一个字符(无缓冲,不中断)
// 
// 特点:
// - 轮询方式(忙等待)
// - 不中断,不阻塞
// - 适合在中断处理函数或 printf() 中使用
// - 保证字符一定被发送出去才返回
// ================================================================
void
uartputc_sync(int c)
{
  // 禁用中断(防止在发送过程中被中断打断)
  push_off();

  // 如果系统处于 panic 状态,无限循环
  if(panicked){
    for(;;)
      ;
  }

  // 等待发送保持寄存器为空(可以发送新字符)
  // 检查 LSR 的 TX_IDLE 位(bit 5)
  while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
    ;  // 忙等待

  // 向发送保持寄存器写入字符
  // UART 会自动开始发送
  WriteReg(THR, c);

  // 重新启用中断
  pop_off();
}

// ================================================================
// 启动 UART 发送(从缓冲区发送字符)
// 
// 特点:
// - 调用者必须持有 uart_tx_lock
// - 从顶层(进程上下文)和底层(中断上下文)都可调用
// - 发送尽可能多的字符
// ================================================================
void
uartstart()
{
  while(1){
    // ============================================================
    // 检查缓冲区是否为空
    // ============================================================
    if(uart_tx_w == uart_tx_r){
      // 发送缓冲区为空,没有字符要发送
      return;
    }
    
    // ============================================================
    // 检查 UART 是否准备好发送
    // ============================================================
    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // UART 发送保持寄存器已满,不能发送新字符
      // 当它准备好时,会产生发送中断,再次调用 uartstart()
      return;
    }
    
    // ============================================================
    // 从缓冲区取出一个字符并发送
    // ============================================================
    int c = uart_tx_buf[uart_tx_r];          // 读取字符
    uart_tx_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;  // 读索引前进
    
    // 唤醒可能正在等待缓冲区空间的 uartputc()
    // 因为现在缓冲区有一个空位了
    wakeup(&uart_tx_r);
    
    // 向 UART 发送字符
    // UART 会自动将这个字符发送出去
    WriteReg(THR, c);
    
    // 继续循环,尝试发送下一个字符
  }
}

// ================================================================
// 从 UART 读取一个字符
// 
// 返回值:
//   - 如果有一个字符等待:返回该字符(0-255)
//   - 如果没有字符:返回 -1
// ================================================================
int
uartgetc(void)
{
  // 检查线路状态寄存器的 RX_READY 位(bit 0)
  if(ReadReg(LSR) & 0x01){
    // 有输入数据等待,从接收保持寄存器读取
    return ReadReg(RHR);
  } else {
    // 没有数据
    return -1;
  }
}

// ================================================================
// UART 中断处理函数
// 
// 被调用的时机:
// 1. 有输入字符到达(接收中断)
// 2. 发送缓冲区空(发送中断)
// 
// 从 trap.c 中调用
// ================================================================
void
uartintr(void)
{
  // ============================================================
  // 处理输入:读取并处理所有到达的字符
  // ============================================================
  while(1){
    int c = uartgetc();      // 读取一个字符
    if(c == -1)              // 没有更多字符
      break;
    consoleintr(c);          // 交给控制台处理
    // consoleintr() 会:
    // 1. 回显字符到屏幕
    // 2. 处理退格、删除行等特殊键
    // 3. 存入控制台缓冲区
    // 4. 唤醒等待输入的进程
  }

  // ============================================================
  // 处理输出:发送缓冲区中等待的字符
  // ============================================================
  // 获取发送锁
  acquire(&uart_tx_lock);
  
  // 启动发送(如果有字符等待,立即发送)
  uartstart();
  
  // 释放锁
  release(&uart_tx_lock);
}

// ================================================================
// UART 工作原理总结
// ================================================================
//
// 1. 发送流程(中断驱动):
//    a. 程序调用 uartputc(c) 写入字符
//    b. 字符放入 uart_tx_buf 缓冲区
//    c. uartstart() 尝试发送
//    d. 如果 UART 空闲,立即发送
//    e. 如果 UART 忙,等待发送中断
//    f. UART 发送完成后触发发送中断
//    g. uartintr() 被调用,再次调用 uartstart()
//    h. 继续发送缓冲区中的下一个字符
//    i. 循环直到缓冲区为空
//
// 2. 接收流程(中断驱动):
//    a. 用户敲击键盘,UART 收到字符
//    b. UART 触发接收中断
//    c. uartintr() 被调用
//    d. 循环调用 uartgetc() 读取所有字符
//    e. 每个字符调用 consoleintr() 处理
//    f. consoleintr() 回显字符并存入控制台缓冲区
//
// 3. 同步发送(轮询方式):
//    a. 程序调用 uartputc_sync(c)
//    b. 禁用中断(push_off())
//    c. 轮询等待 UART 空闲(LSR_TX_IDLE)
//    d. 直接写入 THR 寄存器
//    e. 重新启用中断(pop_off())
//    f. 用于 printf() 和控制台回显
//
// ================================================================
// 环形缓冲区状态
// ================================================================
//
// uart_tx_buf 大小 = 32
// 
// 示例:
// 1. 空状态:
//    w = 0, r = 0
//    [ ] [ ] [ ] [ ] [ ] ... [ ]
//     r/w
//
// 2. 写入 "hello":
//    w = 5, r = 0
//    [h] [e] [l] [l] [o] [ ] ... [ ]
//     r              w
//
// 3. 发送 "he":
//    w = 5, r = 2
//    [h] [e] [l] [l] [o] [ ] ... [ ]
//          r         w
//
// 4. 缓冲区满(w + 1 == r):
//    只有一个空位保留,用于区分空和满
//    满条件:(w + 1) % SIZE == r
//
// ================================================================
// UART 寄存器访问
// ================================================================
//
// UART 是内存映射设备,基地址 UART0
// 例如:UART0 = 0x10000000 (在 QEMU 中)
//
// 访问示例:
// ReadReg(LSR)  → 读取 0x10000005 地址的值
// WriteReg(THR, 'A') → 写入 0x10000000 地址,发送字符 'A'
//
// ================================================================
// 中断处理流程
// ================================================================
//
// 接收中断:
// 键盘输入 → UART 收到字符 → 触发接收中断
// → uartintr() → uartgetc() → consoleintr() → 回显/存入缓冲区
//
// 发送中断:
// 应用程序写入 → uartputc() → 存入缓冲区 → uartstart() → 发送字符
// → 发送完成 → 触发发送中断 → uartintr() → uartstart() → 发送下一个
//
// ================================================================

这里介绍一下有关睡眠锁的代码:

// 互斥自旋锁

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "spinlock.h"
#include "riscv.h"
#include "proc.h"
#include "defs.h"

// 初始化自旋锁
// 参数:lk - 锁结构体指针,name - 锁的名称(用于调试)
void
initlock(struct spinlock *lk, char *name)
{
  lk->name = name;      // 设置锁名称
  lk->locked = 0;       // 初始状态:未锁定
  lk->cpu = 0;          // 初始没有CPU持有该锁
}

// 获取自旋锁
// 循环(自旋)直到成功获取锁
void
acquire(struct spinlock *lk)
{
  push_off();           // 关闭中断,避免死锁
  
  // 检查当前CPU是否已经持有该锁(递归获取锁是非法行为)
  if(holding(lk))
    panic("acquire");

  // 在RISC-V上,sync_lock_test_and_set 会被编译为原子交换指令:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)   // 原子交换,带获取屏障
  // 循环直到成功获取锁(原值为0表示锁是空闲的)
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  // 告诉C编译器和处理器:不要将加载或存储操作移动到这条语句之后
  // 确保临界区的内存引用严格发生在锁被获取之后
  // 在RISC-V上,这会生成一条 fence 指令(内存屏障)
  __sync_synchronize();

  // 记录锁的持有者信息,用于 holding() 检查和调试
  lk->cpu = mycpu();
}

// 释放自旋锁
void
release(struct spinlock *lk)
{
  // 检查当前CPU是否持有该锁
  if(!holding(lk))
    panic("release");

  // 清除锁的持有者信息
  lk->cpu = 0;

  // 告诉C编译器和CPU:不要将加载或存储操作移动到这条语句之后
  // 确保临界区的所有写操作在释放锁之前对其他CPU可见
  // 且临界区的读操作严格发生在锁释放之前
  // 在RISC-V上,这会生成一条 fence 指令(内存屏障)
  __sync_synchronize();

  // 释放锁,相当于执行 lk->locked = 0
  // 这里不使用C语言的赋值操作,因为C标准允许赋值可能被实现为
  // 多条存储指令(在某些架构上可能不是原子操作)
  // 在RISC-V上,sync_lock_release 会被编译为原子交换:
  //   s1 = &lk->locked
  //   amoswap.w zero, zero, (s1)   // 原子交换,将0写入locked
  __sync_lock_release(&lk->locked);

  pop_off();            // 恢复中断状态(重新启用中断)
}

// 检查当前CPU是否持有该锁
// 注意:调用此函数时中断必须处于关闭状态
int
holding(struct spinlock *lk)
{
  int r;
  // 锁被锁定 且 锁的持有者是当前CPU
  r = (lk->locked && lk->cpu == mycpu());
  return r;
}

// push_off/pop_off 类似于 intr_off()/intr_on(),但它们是配对使用的:
// 需要两次 pop_off() 才能撤销两次 push_off() 的效果
// 另外,如果中断初始是关闭的,那么 push_off/pop_off 会保持中断关闭

// 关闭中断(支持嵌套调用)
void
push_off(void)
{
  int old = intr_get();        // 保存当前中断状态

  intr_off();                  // 关闭中断
  
  // 如果是第一次调用(嵌套计数为0),保存之前的中断启用状态
  if(mycpu()->noff == 0)
    mycpu()->intena = old;     // 记录中断原本是开启还是关闭的
  
  mycpu()->noff += 1;          // 嵌套计数器加1
}

// 恢复中断状态(与 push_off 配对使用)
void
pop_off(void)
{
  struct cpu *c = mycpu();
  
  // 检查中断是否已被关闭(调用pop_off时中断必须是关闭的)
  if(intr_get())
    panic("pop_off - interruptible");
  
  // 检查嵌套计数器:不能小于1(必须与push_off配对)
  if(c->noff < 1)
    panic("pop_off");
  
  c->noff -= 1;                // 嵌套计数器减1
  
  // 如果嵌套计数器归零,且中断原本是开启的,则重新启用中断
  if(c->noff == 0 && c->intena)
    intr_on();
}

锁的名称和cpu结构:

// Mutual exclusion lock.
struct spinlock {
  uint locked;       // Is the lock held?

  // For debugging:
  char *name;        // Name of lock.
  struct cpu *cpu;   // The cpu holding the lock.
};

struct cpu {
  struct proc *proc;          // The process running on this cpu, or null.
  struct context context;     // swtch() here to enter scheduler().
  int noff;                   // Depth of push_off() nesting.
  int intena;                 // Were interrupts enabled before push_off()?
};
/*
noff - 中断关闭嵌套计数器
作用:记录当前CPU上 push_off() 被调用的嵌套深度。

工作原理:

每次调用 push_off() 时,noff 加 1

每次调用 pop_off() 时,noff 减 1

当 noff == 0 时,表示中断处于"正常"状态(根据 intena 决定是否开启)


intena - 中断启用状态保存
作用:保存调用 push_off() 之前中断是开启还是关闭的。

工作原理:

在 push_off() 中,如果是第一次调用(noff == 0),保存当前中断状态到 intena

在 pop_off() 中,当嵌套计数器归零(noff == 0)时,根据 intena 决定是否重新开启中断
*/

为什么需要保存这个状态?

考虑两种场景:

场景1:中断原本是开启的

// 中断开启状态
push_off();    // old = 1 (开启), intena = 1, 关闭中断
// ... 临界区 ...
pop_off();     // noff == 0 且 intena == 1, 重新开启中断 ✓

场景2:中断原本是关闭的

intr_off();    // 手动关闭中断
// 中断关闭状态
push_off();    // old = 0 (关闭), intena = 0, 继续保持关闭
// ... 临界区 ...
pop_off();     // noff == 0 但 intena == 0, 保持中断关闭 ✓

如果没有 intena,pop_off() 可能会错误地开启本来就被关闭的中断。
在讲解非常重要的内存管理之前,我们需要讲解一下系统调用,因为这和之前的定时器中断处理有一些交叉的内容,我们先贴出流程图:
在这里插入图片描述
不行,这个图涉及到了一些内存映射的地方,我们还是先简单讲解一下内存管理:
按照main中函数的初始化结果,显示初始化物理内存,再初始化页管理:

// 物理内存分配器,用于分配:
// 1. 用户进程的内存空间
// 2. 内核栈(kernel stacks)
// 3. 页表页(page-table pages)
// 4. 管道缓冲区(pipe buffers)
// 分配单位:整页(4096字节)

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "spinlock.h"
#include "riscv.h"
#include "defs.h"

// 函数声明:释放从pa_start到pa_end范围内的所有物理页
void freerange(void *pa_start, void *pa_end);

// 由链接脚本kernel.ld定义的符号,标记内核代码结束位置
// 即内核占用内存的结束地址,也是可用物理内存的起始地址
extern char end[]; // first address after kernel.
                   // defined by kernel.ld.

// 空闲页链表节点结构
// 每个空闲物理页本身被用作链表节点,不浪费额外内存
struct run {
  struct run *next;  // 指向下一个空闲页
};

// 内核内存分配器核心数据结构
struct {
  struct spinlock lock;    // 保护空闲链表的自旋锁
  struct run *freelist;    // 空闲页链表的头指针
} kmem;

// 初始化内存分配器
// 在系统启动时被main.c调用
void
kinit()
{
  initlock(&kmem.lock, "kmem");  // 初始化自旋锁
  // 将内核结束地址(end)到物理内存顶部(PHYSTOP)之间的所有页释放到空闲链表
  freerange(end, (void*)PHYSTOP);
}

// 释放从pa_start到pa_end之间的所有物理页到空闲链表
// 用于初始化内存分配器
void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  // 将起始地址向上对齐到页边界(PGROUNDUP)
  // 确保从完整的页开始释放
  p = (char*)PGROUNDUP((uint64)pa_start);
  // 遍历每个完整的页,调用kfree释放
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
    kfree(p);
}

// 释放单个物理页
// 参数pa:要释放的物理页起始地址(必须页对齐)
// 正常情况下,pa应该来自kalloc()的返回值
// 例外情况:在kinit()初始化时调用(用于释放内核后的所有内存)
void
kfree(void *pa)
{
  struct run *r;

  // 安全性检查:
  // 1. 地址必须页对齐(4096字节对齐)
  // 2. 不能释放内核自身占用的内存(地址必须 >= end)
  // 3. 不能释放超出物理内存范围的地址(地址必须 < PHYSTOP)
  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // 用垃圾值填充整个页面(全填充为1)
  // 目的是捕获悬空指针(dangling pointers):
  // 如果有代码继续使用已释放的内存,会发现数据被破坏了
  memset(pa, 1, PGSIZE);

  // 将物理页转换为空闲链表节点
  r = (struct run*)pa;

  // 获取自旋锁,将页面插入空闲链表头部
  acquire(&kmem.lock);
  r->next = kmem.freelist;  // 当前页指向原来的头
  kmem.freelist = r;        // 更新头指针为当前页
  release(&kmem.lock);
}

// 分配一个4096字节的物理页
// 返回内核可以使用的指针(物理地址的直接映射)
// 如果内存不足,返回0
void *
kalloc(void)
{
  struct run *r;

  // 获取自旋锁,从空闲链表头部取出一个页面
  acquire(&kmem.lock);
  r = kmem.freelist;       // 取出头节点
  if(r)
    kmem.freelist = r->next; // 更新头指针为下一个节点
  release(&kmem.lock);

  // 如果分配成功,用垃圾值填充页面(填充为5)
  // 目的与kfree相同:捕获使用未初始化内存或悬空指针的bug
  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  
  return (void*)r;  // 返回物理页地址(在直接映射区中)
}

空闲页本身作为链表节点,无需额外内存管理链表
每个空闲页的前8字节存储指向下一空闲页的指针
这里的页面大小是隐含的:
例如下面的代码,

// 例如:为用户进程分配内存(可能需要多页)
int uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz) {
  char *mem;
  uint64 a;
  for(a = oldsz; a < newsz; a += PGSIZE) {
    mem = kalloc();  // 每次分配一页
    // ...
  }
}

物理内存分配方法初始化完之后就是分页管理了;
具体的也也有关的函数都在下面,这里还有链接器的相关内容,许多地址定义是在链接器里面的:

.text : {
    *(.text .text.*)      // 1. 先放普通内核代码
    . = ALIGN(0x1000);    // 2. 对齐到页边界
    _trampoline = .;       // 3. trampoline 从这里开始
    *(trampsec)           // 4. 放入 trampoline.S 的代码
    . = ALIGN(0x1000);    // 5. 再对齐到页边界
    ASSERT(. - _trampoline == 0x1000, ...);  // 6. 确保正好一页
    PROVIDE(etext = .);   // 7. etext = trampoline 结束之后
}

_trampoline这个地址很重要,牢记

#define KERNBASE 0x80000000L
#define PHYSTOP (KERNBASE + 128*1024*1024)

// map the trampoline page to the highest address,
// in both user and kernel space.
#define TRAMPOLINE (MAXVA - PGSIZE)

// map kernel stacks beneath the trampoline,
// each surrounded by invalid guard pages.
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)

// User memory layout.
// Address zero first:
//   text
//   original data and bss
//   fixed-size stack
//   expandable heap
//   ...
//   TRAPFRAME (p->trapframe, used by the trampoline)
//   TRAMPOLINE (the same page as in the kernel)
#define TRAPFRAME (TRAMPOLINE - PGSIZE)

所以 TRAMPOLINE 就是 虚拟地址空间的最高一页(最后一页),这个位置对所有进程(包括内核)都是固定且相同的:
内核页表映射:kvmmap(TRAMPOLINE, trampoline, PGSIZE, …)
用户页表也映射同一物理页到同一虚拟地址

// 内核页表管理
// 负责管理内核的虚拟地址到物理地址的映射
// 支持Sv39分页方案(三级页表)

#include "param.h"
#include "types.h"
#include "memlayout.h"
#include "elf.h"
#include "riscv.h"
#include "defs.h"
#include "fs.h"

/*
 * 内核页表(全局变量)
 * 用于内核空间的地址转换
 */
pagetable_t kernel_pagetable;

// 由链接脚本 kernel.ld 定义,标记内核代码段结束位置
extern char etext[];  // kernel.ld sets this to end of kernel code.

// 陷阱处理跳板代码(汇编实现)
extern char trampoline[]; // trampoline.S

/*
 * 创建内核的直接映射页表
 * 将物理地址直接映射到相同的虚拟地址(恒等映射)
 * 在系统启动时调用一次
 */
void
kvminit()
{
  // 分配一个物理页作为内核页表的根页表
  kernel_pagetable = (pagetable_t) kalloc();
  memset(kernel_pagetable, 0, PGSIZE);  // 清零页表

  // 映射UART(串口)寄存器
  // UART0是物理地址,也映射到相同的虚拟地址
  kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // 映射virtio磁盘接口(MMIO)
  kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // 映射CLINT(核心本地中断控制器),大小0x10000字节
  kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // 映射PLIC(平台级中断控制器),大小0x400000字节
  kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // 映射内核代码段:只读且可执行(不可写)
  // KERNBASE是内核起始地址(通常0x80000000)
  kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // 映射内核数据段和剩余的物理内存:可读可写
  // 从etext到PHYSTOP的所有物理内存都直接映射
  kvmmap((uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // 映射陷阱跳板代码到虚拟地址空间的最高处(TRAMPOLINE)
  // 这个映射用于用户态和内核态之间的切换
  kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

// 切换到内核页表,并启用分页
// 在每个CPU核初始化时调用
void
kvminithart()
{
  // 将satp寄存器设置为内核页表的物理地址
  // MAKE_SATP将页表地址转换为satp需要的格式
  w_satp(MAKE_SATP(kernel_pagetable));
  // 刷新TLB(转换后备缓冲器),确保之前的映射失效
  sfence_vma();
}

// 在页表中查找虚拟地址va对应的PTE(页表项)
// 返回PTE的指针,如果alloc!=0则自动分配缺失的页表页
//
// RISC-V Sv39方案有三级的页表:
// 每个页表页包含512个64位的PTE
// 64位虚拟地址分为五个字段:
//   39..63 -- 必须为0(只使用低39位)
//   30..38 -- 9位,L2索引(顶级页表)
//   21..29 -- 9位,L1索引(中间级页表)
//   12..20 -- 9位,L0索引(最末级页表)
//    0..11 -- 12位,页内偏移
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  // 检查虚拟地址是否超出Sv39支持的范围
  if(va >= MAXVA)
    panic("walk");

  // 从顶级(L2)开始,逐级向下查找
  for(int level = 2; level > 0; level--) {
    // 获取当前级别的PTE指针
    // PX(level, va) 提取对应级别的索引
    pte_t *pte = &pagetable[PX(level, va)];
    
    if(*pte & PTE_V) {
      // PTE有效,提取下一级页表的物理地址
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else {
      // PTE无效,如果需要分配则创建新的页表页
      if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
        return 0;  // 分配失败或不需要分配
      memset(pagetable, 0, PGSIZE);  // 清零新页表
      // 设置PTE指向新页表,并标记为有效
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  // 返回最末级(L0)的PTE指针
  return &pagetable[PX(0, va)];
}

// 查找虚拟地址对应的物理地址
// 如果未映射则返回0
// 只能用于查找用户页表
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
  pte_t *pte;
  uint64 pa;

  if(va >= MAXVA)
    return 0;

  // 查找PTE(不分配新页表页)
  pte = walk(pagetable, va, 0);
  if(pte == 0)
    return 0;
  if((*pte & PTE_V) == 0)  // PTE无效
    return 0;
  if((*pte & PTE_U) == 0)  // 不是用户页(安全检查)
    return 0;
  // 从PTE中提取物理地址
  pa = PTE2PA(*pte);
  return pa;
}

// 在内核页表中添加映射
// 仅在系统启动时使用
// 不刷新TLB,不启用分页
void
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
  if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
    panic("kvmmap");
}

// 将内核虚拟地址转换为物理地址
// 仅用于栈上的地址(因为栈在直接映射区域)
// 假设va是页对齐的
uint64
kvmpa(uint64 va)
{
  uint64 off = va % PGSIZE;  // 页内偏移
  pte_t *pte;
  uint64 pa;
  
  // 在内核页表中查找
  pte = walk(kernel_pagetable, va, 0);
  if(pte == 0)
    panic("kvmpa");
  if((*pte & PTE_V) == 0)
    panic("kvmpa");
  // 提取物理页地址,加上页内偏移
  pa = PTE2PA(*pte);
  return pa+off;
}

// 创建从虚拟地址va开始的多个页的映射
// 映射到从物理地址pa开始的物理页
// va和size可能不是页对齐的(函数内部会处理)
// 成功返回0,如果walk()无法分配页表页则返回-1
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
  uint64 a, last;
  pte_t *pte;

  // 对齐到页边界
  a = PGROUNDDOWN(va);
  last = PGROUNDDOWN(va + size - 1);
  
  // 遍历需要映射的每个页
  for(;;){
    // 查找PTE,如果不存在则分配(alloc=1)
    if((pte = walk(pagetable, a, 1)) == 0)
      return -1;
    // 检查是否已经被映射(防止重复映射)
    if(*pte & PTE_V)
      panic("remap");
    // 设置PTE:物理地址 + 权限位 + 有效位
    *pte = PA2PTE(pa) | perm | PTE_V;
    // 如果已经映射到最后一个页,退出循环
    if(a == last)
      break;
    // 移动到下一个页
    a += PGSIZE;
    pa += PGSIZE;
  }
  return 0;
}

// 从虚拟地址va开始,删除npages个页的映射
// va必须是页对齐的
// 如果do_free为真,同时释放物理内存
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
  uint64 a;
  pte_t *pte;

  if((va % PGSIZE) != 0)
    panic("uvmunmap: not aligned");

  for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
    // 查找PTE(不分配新页)
    if((pte = walk(pagetable, a, 0)) == 0)
      panic("uvmunmap: walk");
    if((*pte & PTE_V) == 0)
      panic("uvmunmap: not mapped");
    if(PTE_FLAGS(*pte) == PTE_V)
      panic("uvmunmap: not a leaf");  // 必须是最末级PTE
    if(do_free){
      // 释放物理内存
      uint64 pa = PTE2PA(*pte);
      kfree((void*)pa);
    }
    // 清除PTE
    *pte = 0;
  }
}

// 创建一个空的用户页表
// 返回0表示内存不足
pagetable_t
uvmcreate()
{
  pagetable_t pagetable;
  // 分配一个物理页作为根页表
  pagetable = (pagetable_t) kalloc();
  if(pagetable == 0)
    return 0;
  memset(pagetable, 0, PGSIZE);
  return pagetable;
}

// 加载用户初始代码到页表的地址0处
// 用于第一个进程(init进程)
// sz必须小于一页
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
  char *mem;

  if(sz >= PGSIZE)
    panic("inituvm: more than a page");
  
  // 分配一页物理内存
  mem = kalloc();
  memset(mem, 0, PGSIZE);
  // 映射到虚拟地址0,权限:可读写执行,用户态可访问
  mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
  // 复制初始代码到该页
  memmove(mem, src, sz);
}

// 为进程分配内存,从oldsz增长到newsz
// newsz和oldsz不必页对齐
// 返回新的大小,出错返回0
uint64
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
  char *mem;
  uint64 a;

  if(newsz < oldsz)
    return oldsz;

  // 将oldsz向上对齐到页边界
  oldsz = PGROUNDUP(oldsz);
  // 逐页分配
  for(a = oldsz; a < newsz; a += PGSIZE){
    mem = kalloc();  // 分配一页物理内存
    if(mem == 0){
      // 分配失败,回退已分配的内存
      uvmdealloc(pagetable, a, oldsz);
      return 0;
    }
    memset(mem, 0, PGSIZE);  // 清零新页
    // 映射到进程虚拟地址空间
    if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0){
      kfree(mem);
      uvmdealloc(pagetable, a, oldsz);
      return 0;
    }
  }
  return newsz;
}

// 释放进程内存,从oldsz缩小到newsz
// oldsz和newsz不必页对齐
// 返回新的进程大小
uint64
uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
  if(newsz >= oldsz)
    return oldsz;

  // 如果跨越了页边界,需要释放部分页
  if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
    int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
    uvmunmap(pagetable, PGROUNDUP(newsz), npages, 1);  // 释放物理内存
  }

  return newsz;
}

// 递归释放页表页
// 所有叶子映射必须已经被移除
void
freewalk(pagetable_t pagetable)
{
  // 一个页表有512个PTE(2^9)
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
      // 这个PTE指向下一级页表(不是叶子节点)
      uint64 child = PTE2PA(pte);
      freewalk((pagetable_t)child);  // 递归释放
      pagetable[i] = 0;
    } else if(pte & PTE_V){
      // 遇到叶子节点但应该已经被移除
      panic("freewalk: leaf");
    }
  }
  // 释放当前页表页本身
  kfree((void*)pagetable);
}

// 释放用户进程的所有内存
// 先释放所有物理页,再释放页表页
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
  if(sz > 0)
    uvmunmap(pagetable, 0, PGROUNDUP(sz)/PGSIZE, 1);  // 释放物理页
  freewalk(pagetable);  // 释放页表页
}

// 复制父进程的页表到子进程
// 同时复制页表和物理内存(写时复制机制前的基础版本)
// 成功返回0,失败返回-1
// 失败时会释放已分配的内存
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;
  char *mem;

  // 逐页复制
  for(i = 0; i < sz; i += PGSIZE){
    if((pte = walk(old, i, 0)) == 0)
      panic("uvmcopy: pte should exist");
    if((*pte & PTE_V) == 0)
      panic("uvmcopy: page not present");
    
    pa = PTE2PA(*pte);           // 物理地址
    flags = PTE_FLAGS(*pte);     // 权限标志
    
    // 分配新页
    if((mem = kalloc()) == 0)
      goto err;
    
    // 复制数据
    memmove(mem, (char*)pa, PGSIZE);
    
    // 映射到子进程的地址空间
    if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
      kfree(mem);
      goto err;
    }
  }
  return 0;

 err:
  // 出错清理:取消映射已分配的页
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}

// 清除PTE的用户态访问位
// 用于exec为用户栈创建保护页
void
uvmclear(pagetable_t pagetable, uint64 va)
{
  pte_t *pte;
  
  pte = walk(pagetable, va, 0);
  if(pte == 0)
    panic("uvmclear");
  // 清除PTE_U位,使用户态无法访问
  *pte &= ~PTE_U;
}

// 从内核向用户空间复制数据
// 复制len字节从src到目标页表pagetable中的虚拟地址dstva
// 成功返回0,错误返回-1
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);  // 目标虚拟页起始地址
    pa0 = walkaddr(pagetable, va0);  // 物理地址
    if(pa0 == 0)
      return -1;
    
    // 计算当前页内可复制的字节数
    n = PGSIZE - (dstva - va0);
    if(n > len)
      n = len;
    
    // 复制到物理地址(内核可直接访问)
    memmove((void *)(pa0 + (dstva - va0)), src, n);

    len -= n;
    src += n;
    dstva = va0 + PGSIZE;  // 移动到下一页
  }
  return 0;
}

// 从用户空间向内核复制数据
// 复制len字节从用户虚拟地址srcva到内核目标dst
// 成功返回0,错误返回-1
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(srcva);  // 源虚拟页起始地址
    pa0 = walkaddr(pagetable, va0);  // 物理地址
    if(pa0 == 0)
      return -1;
    
    // 计算当前页内可复制的字节数
    n = PGSIZE - (srcva - va0);
    if(n > len)
      n = len;
    
    // 从物理地址复制到内核
    memmove(dst, (void *)(pa0 + (srcva - va0)), n);

    len -= n;
    dst += n;
    srcva = va0 + PGSIZE;  // 移动到下一页
  }
  return 0;
}

// 从用户空间复制一个以null结尾的字符串到内核
// 从srcva复制最多max字节到dst,遇到'\0'停止
// 成功返回0,错误返回-1
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  uint64 n, va0, pa0;
  int got_null = 0;

  while(got_null == 0 && max > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    
    n = PGSIZE - (srcva - va0);
    if(n > max)
      n = max;

    char *p = (char *) (pa0 + (srcva - va0));
    while(n > 0){
      if(*p == '\0'){
        *dst = '\0';
        got_null = 1;
        break;
      } else {
        *dst = *p;
      }
      --n;
      --max;
      p++;
      dst++;
    }

    srcva = va0 + PGSIZE;
  }
  
  if(got_null){
    return 0;
  } else {
    return -1;  // 没有找到null终止符
  }
}

关于页表的查询和创建都很熟悉了:
在这里插入图片描述

先看下xv6的内存映射关系:
有个小细节:

符号定义位置地址值含义
trampoline链接器脚本 kernel.ld0x8000xxxx(具体看内核代码大小)物理地址,在内核代码段末尾
TRAMPOLINEmemlayout.h0x3FFFFFF000虚拟地址,在虚拟地址空间最顶端

因为qemu的问题:

虚拟地址空间(Sv39,39位有效):
0x0000000000 ~ 0x3FFFFFF000 | 用户空间
0x3FFFFFF000 | TRAMPOLINE(最高一页)
0x4000000000 | MAXVA(上限)

物理内存(QEMU 默认):
0x80000000 | 内核加载地址(KERNBASE)
0x8000xxxx | trampoline 物理页

trampoline 是物理页的实际位置,在内核加载时就确定了
TRAMPOLINE 是刻意选的高虚拟地址,和物理位置无关

kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
//      ↑ 虚拟地址    ↑ 物理地址

所以它们的关系是:
物理页在 0x8000xxxx(内核代码区)
虚拟入口在 0x3FFFFFF000(虚拟空间顶端)
通过页表建立映射,CPU 访问 0x3FFFFFF000 时实际读写的是 0x8000xxxx 那页物理内存。
在这里插入图片描述

这里得明白,内核页表一开始是在内核数据区:

对象位置说明
kernel_pagetable 变量内核数据区(BSS段)编译时确定,地址在 etext 附近
*kernel_pagetable(页表页)kalloc() 分配的物理页运行时动态分配,物理地址在 end ~ PHYSTOP 之间
页表页的虚拟访问地址直接映射区因为直接映射,虚拟地址 = 物理地址

这里的地址隐射很简洁,采用直接映射

对象位置怎么访问
kernel_pagetable 变量(存地址的 8 字节)内核 BSS/Data 段(etext 之前)直接访问
*kernel_pagetable(4096 字节的根页表页)直接映射区(etext ~ PHYSTOP虚拟地址 = 物理地址

因为页表页本身也在直接映射区,内核才能像读写普通内存一样操作页表。比如 walk() 函数里的:

pte_t *pte = &pagetable[PX(level, va)];

这里 pagetable 就是直接映射区的虚拟地址。如果页表页不在直接映射区,内核连自己的页表都找不着。(我记得408有考过得找到根页表页的。)
这里注意虚拟地址高位置的一些数据,内核栈是有保护页的

#define KSTACK(p) (TRAMPOLINE - ((p)+1) * 2*PGSIZE)

这里要注意有trampoline和内核栈都有两次映射:

// kvminit() 中

// 第1次:直接映射(恒等映射)
// 这一行把 etext 到 PHYSTOP 的所有物理内存直接映射
kvmmap((uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

// 第2次:高地址映射
// 这一行把 trampoline 的物理页额外映射到 TRAMPOLINE(高地址)
kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

第一次映射:kvmmap(etext, etext, PHYSTOP-etext, …) 已经把包含 trampoline 的那块物理内存直接映射了(虚拟地址 = 物理地址)。
第二次映射:kvmmap(TRAMPOLINE, trampoline, …) 把同一物理页又映射到了高地址 TRAMPOLINE
内核栈的第二次映射在进程初始化函数中:

映射在哪里完成代码
第一次(直接映射)kvminit()kvmmap(etext, etext, PHYSTOP-etext, ...)
第二次(高地址 + Guard page)procinit()kvmmap(KSTACK(p), pa, PGSIZE, ...)
下面是具体代码:
procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");

      // Allocate a page for the process's kernel stack.
      // Map it high in memory, followed by an invalid
      // guard page.
      char *pa = kalloc();
      if(pa == 0)
        panic("kalloc");
      uint64 va = KSTACK((int) (p - proc));
      kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
      p->kstack = va;
  }

为何需要两次映射呢:
Trampoline 的高地址映射:地址固定,跨页表共享
TRAMPOLINE 被映射到虚拟地址空间的最高处(接近 MAXVA),这个位置是刻意固定的。
为什么?
RISC-V 的 trap 机制要求:当 CPU 从用户态进入内核态时,需要跳到一个固定的入口地址执行代码(由 stvec 寄存器设置)。但这个切换过程中,页表需要从用户页表切换到内核页表。
如果 trampoline 只存在于直接映射区:
用户页表没有直接映射区(用户页表只映射用户内存和 trampoline)
切换页表后,CPU 如果还在直接映射区的地址执行,就会因为用户页表没有这个映射而崩溃
所以 xv6 把 trampoline 映射到同一个高地址,同时存在于:
用户页表中(只映射 trampoline 这一页)
内核页表中(也映射到同一高地址)
这样,trap 发生时:
CPU 还在用户页表下,跳转到 TRAMPOLINE(高地址)—— 能访问
trampoline 代码执行,切换到内核页表 —— TRAMPOLINE 地址不变,仍然能访问
从内核返回用户态时,反向操作同理
意义:trampoline 的高地址是一个"页表切换的桥梁",地址必须固定且同时存在于用户和内核页表中。
内核栈的高地址映射(KSTACK§)意义在于进程隔离和栈溢出保护。
为什么不能直接用直接映射区?
直接映射区是 identity mapping,虚拟地址 = 物理地址,所有内存连续排列。如果内核栈放在这里:

直接映射区:| … | 进程A栈 | 进程B栈 | 进程C栈 | … |

栈之间没有空隙,进程 A 的内核栈溢出会直接覆盖进程 B 的内核栈,而且静默无声,极难 debug。
高地址映射解决了什么?

#define KSTACK(p) (TRAMPOLINE - ((p)+1) * 2*PGSIZE)

每个进程的内核栈占 2 页间隔:
第 1 页:Guard page(不映射,PTE 无效)
第 2 页:实际的内核栈

#define KSTACK§ (TRAMPOLINE - (§+1) * 2*PGSIZE)

隔离:每个进程的内核栈在虚拟地址空间上是分离的
保护:Guard page 无物理内存映射,栈溢出即触发 page fault,立刻 panic 暴露问题
独立:每个进程有自己的 p->kstack,上下文切换时只需换页表和 sscratch
意义:内核栈的高地址映射让"Guard page 保护"和"进程间隔离"成为可能,这在连续的直接映射区里做不到。

下面开始详细介绍中断处理,从用户的系统调用逐渐分析:

sub entry {
    my $name = shift;
    print ".global $name\n";
    print "${name}:\n";
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}
	
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");

这里的代码会被插入到每个系统调用的位置,

.global fork
fork:
 li a7, SYS_fork    # 把系统调用号 1 放到 a7 寄存器
 ecall              # ← 触发系统调用!
 ret                # 从系统调用返回

在这里插入图片描述
在系统调用之前得理清楚一些异常和中断的流程:

void
main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors
    trapinithart();  // install kernel trap vector
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    binit();         // buffer cache
    iinit();         // inode cache
    fileinit();      // file table
    virtio_disk_init(); // emulated hard disk
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();    // turn on paging
    trapinithart();   // install kernel trap vector
    plicinithart();   // ask PLIC for device interrupts
  }

  scheduler();        
}

这里的中断只安装了内核中断:

void
trapinithart(void)
{
  w_stvec((uint64)kernelvec);
}

ecall的第三个作用stevc指向的位置是在啥时候被填入的呢?
得从main流程里面入手:


// 设置第一个用户进程(init进程)
// 在系统启动时被 main.c 调用,只执行一次
void
userinit(void)
{
  struct proc *p;  // 进程控制块指针

  // 1. 分配一个进程控制块(proc结构体)
  // allocproc() 会:
  //   - 在进程表中找到一个空闲槽位
  //   - 分配内核栈(KSTACK)
  //   - 创建用户页表(pagetable)
  //   - 分配并设置 trapframe
  //   - 初始化进程状态为 UNUSED → USED
  p = allocproc();
  initproc = p;  // 保存 init 进程的指针,全局变量
  
  // 2. 为 init 进程加载初始代码
  // uvminit() 会:
  //   - 分配一页物理内存(kalloc)
  //   - 将 initcode 复制到该页
  //   - 映射到进程虚拟地址空间的 0x0 处
  //   - 设置权限:R|W|X|U(可读可写可执行,用户态可访问)
  uvminit(p->pagetable, initcode, sizeof(initcode));
  
  // 3. 设置进程内存大小(虚拟地址空间范围)
  // 初始代码占一页(4096字节),所以进程大小为一页
  p->sz = PGSIZE;  // sz = 4096,表示虚拟地址 0 ~ 4095 可用

  // 4. 配置 trapframe,为第一次从内核态返回到用户态做准备
  // trapframe 保存了用户进程的寄存器状态
  p->trapframe->epc = 0;      // 用户态程序计数器 = 0
                              // 即从虚拟地址 0 开始执行(initcode)
  
  p->trapframe->sp = PGSIZE;  // 用户态栈指针 = 4096
                              // 因为代码在地址0,栈从页顶向下增长
                              // 这样栈和代码在同一页中,栈向下增长
                              // 地址范围:0~4095,栈从4096开始向下

  // 5. 设置进程名称(用于调试)
  safestrcpy(p->name, "initcode", sizeof(p->name));
  
  // 6. 设置当前工作目录为根目录
  // namei("/") 查找根目录的 inode
  p->cwd = namei("/");

  // 7. 将进程状态设置为 RUNNABLE(可运行)
  // 此时调度器可以调度该进程执行
  p->state = RUNNABLE;

  // 8. 释放进程锁
  // allocproc() 时获取了锁,现在释放
  release(&p->lock);
}

这里设置了第一个进程,
返回地址和内核栈顶是写死的

// ============================================
// 分配一个新的进程控制块(proc structure)
// 在进程表中查找一个空闲槽位,并初始化
// ============================================
static struct proc*
allocproc(void)
{
  struct proc *p;  // 指向新进程的指针

  // ==========================================
  // 第一步:在进程表中查找空闲槽位
  // ==========================================
  // 遍历全局进程表 proc[],NPROC 是最大进程数
  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);  // 获取当前进程的锁,防止并发修改
    
    // 如果进程状态是 UNUSED(未使用),表示这是一个空闲槽位
    if(p->state == UNUSED) {
      goto found;  // 找到空闲槽位,跳转到初始化部分
    } else {
      release(&p->lock);  // 该进程正在使用,释放锁后继续查找
    }
  }
  return 0;  // 进程表已满,分配失败

found:
  // ==========================================
  // 第二步:分配进程ID
  // ==========================================
  // allocpid() 返回一个全局唯一的进程ID(从1开始递增)
  p->pid = allocpid();

  // ==========================================
  // 第三步:分配 trapframe 页
  // ==========================================
  // trapframe 用于保存用户进程的寄存器状态
  // 当进程从用户态陷入内核态时,寄存器保存在这里
  // 当从内核态返回用户态时,从这里恢复寄存器
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);  // 分配失败,释放锁
    return 0;
  }
  // trapframe 页的内容暂时未初始化
  // 稍后由 userinit() 或 fork() 设置具体值

  // ==========================================
  // 第四步:创建用户页表
  // ==========================================
  // proc_pagetable() 创建进程的用户页表
  // 会映射:
  //   1. TRAMPOLINE(陷阱跳板代码)到固定高地址
  //   2. TRAPFRAME(陷阱帧)到另一个固定高地址
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    // 创建失败,释放已分配的资源
    freeproc(p);  // 释放 trapframe 页
    release(&p->lock);
    return 0;
  }

  // ==========================================
  // 第五步:初始化上下文(用于第一次调度)
  // ==========================================
  // context 结构体保存了内核线程的寄存器状态
  // 用于调度器切换进程时保存/恢复上下文
  memset(&p->context, 0, sizeof(p->context));  // 清零上下文
  
  // 设置返回地址 ra = forkret
  // 当调度器第一次调度这个进程时,会从 forkret 开始执行
  p->context.ra = (uint64)forkret;
  
  // 设置栈指针 sp = kstack + PGSIZE
  // kstack 指向内核栈的起始地址(低地址)
  // + PGSIZE 后指向栈顶(高地址),因为栈向下增长
  p->context.sp = p->kstack + PGSIZE;

  // ==========================================
  // 第六步:返回新进程
  // ==========================================
  // 注意:此时 p->state 仍然是 UNUSED
  // 调用者(如 userinit 或 fork)会设置状态为 RUNNABLE
  // 并且会持有 p->lock,调用者需要负责释放
  return p;
}

最关键的就是:

  // 设置返回地址 ra = forkret
  // 当调度器第一次调度这个进程时,会从 forkret 开始执行
  p->context.ra = (uint64)forkret;
  
  // 设置栈指针 sp = kstack + PGSIZE
  // kstack 指向内核栈的起始地址(低地址)
  // + PGSIZE 后指向栈顶(高地址),因为栈向下增长
  p->context.sp = p->kstack + PGSIZE;

进程里面的一些地址是后映射的:

// ============================================
// 为进程创建用户页表
// 参数: p - 进程控制块指针
// 返回: 页表根指针(物理地址),失败返回0
// ============================================
pagetable_t
proc_pagetable(struct proc *p)
{
  pagetable_t pagetable;  // 页表根指针

  // ==========================================
  // 第一步:创建空的用户页表
  // ==========================================
  // uvmcreate() 会:
  //   1. kalloc() 分配一个物理页作为根页表
  //   2. memset() 清零整个页表
  //   3. 返回页表的物理地址
  pagetable = uvmcreate();
  if(pagetable == 0)
    return 0;  // 内存分配失败

  // ==========================================
  // 第二步:映射 TRAMPOLINE(跳板代码)
  // ==========================================
  // TRAMPOLINE 是虚拟地址空间中最高的一个页(MAXVA - PGSIZE)
  // 作用:用户态和内核态切换时的跳板代码
  // 
  // 特点:
  //   - 所有进程共享同一份 trampoline 代码
  //   - 物理地址是 trampoline(在 kernel 段)
  //   - 权限:R-X(只读+可执行)
  //   - 没有 PTE_U 标志,所以用户态不能直接访问
  //   - 只在内核态访问(用户态→内核态切换时使用)
  if(mappages(pagetable, TRAMPOLINE, PGSIZE,
              (uint64)trampoline, PTE_R | PTE_X) < 0){
    uvmfree(pagetable, 0);  // 释放已分配的页表
    return 0;
  }

  // ==========================================
  // 第三步:映射 TRAPFRAME(陷阱帧)
  // ==========================================
  // TRAPFRAME 在 TRAMPOLINE 下方(紧挨着)
  // 作用:保存进程在用户态执行时的寄存器状态
  //
  // 特点:
  //   - 每个进程有自己独立的 trapframe 页
  //   - 物理地址:p->trapframe(通过 kalloc 分配)
  //   - 权限:RW-(可读写,不可执行)
  //   - 没有 PTE_U 标志,用户态不能直接访问
  //   - 内核态可以读写,用于保存/恢复用户寄存器
  if(mappages(pagetable, TRAPFRAME, PGSIZE,
              (uint64)(p->trapframe), PTE_R | PTE_W) < 0){
    // 映射失败,需要回滚
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);  // 取消 TRAMPOLINE 映射
    uvmfree(pagetable, 0);  // 释放页表
    return 0;
  }

  return pagetable;  // 成功返回页表根指针
}

映射完之后地址是这样的:

虚拟地址空间(Sv39,39位地址):

MAXVA (0x3FFFFFFF000) ← 最高虚拟地址
┌─────────────────────┐
│ TRAMPOLINE │ ← 地址: MAXVA - PGSIZE
│ (1页, 4096字节) │ ← 所有进程共享
│ 权限: R-X │ ← 用于陷阱处理
├─────────────────────┤
│ TRAPFRAME │ ← 地址: TRAMPOLINE - PGSIZE
│ (1页, 4096字节) │ ← 每个进程独立
│ 权限: RW- │ ← 保存用户寄存器
├─────────────────────┤
│ │
│ 进程的其他内存 │ ← 代码、数据、堆、栈等
│ (通过其他函数映射) │
│ │
│ │
│ 用户栈 (向下增长) │
│ 堆 (向上增长) │
│ 代码段 │
│ │
└─────────────────────┘
0x0 ← 虚拟地址起点

main最后调用了scheduler,

// 调度器(Scheduler)
// 运行在CPU核上的内核线程,负责选择并切换到可运行的进程
// 这是一个无限循环,永不返回
void
scheduler(void)
{
  struct proc *p;        // 当前遍历的进程指针
  struct cpu *c = mycpu();  // 当前CPU核心的结构体指针
  
  c->proc = 0;  // 初始时,当前CPU没有运行任何进程
  
  // 调度器主循环:永远运行
  for(;;){
    // 开启中断:允许设备中断发生
    // 这是为了避免死锁 - 在调度循环中允许设备中断
    // 注意:此时没有持有任何进程锁,所以是安全的
    intr_on();
    
    int nproc = 0;  // 统计当前存在的进程数量(非UNUSED状态)
    
    // 遍历进程表,查找所有可运行的进程
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);  // 获取当前进程的锁,保护进程状态
      
      // 统计非空闲进程的数量
      if(p->state != UNUSED) {
        nproc++;
      }
      
      // 如果发现一个可运行(RUNNABLE)的进程
      if(p->state == RUNNABLE) {
        // 1. 将进程状态改为运行中(RUNNING)
        p->state = RUNNING;
        
        // 2. 记录当前CPU正在运行该进程
        c->proc = p;
        
        // 3. 切换到该进程
        // swtch() 是汇编函数,保存当前上下文(调度器上下文)
        // 恢复目标进程的上下文(进程的内核栈上下文)
        // 注意:此时切换到了进程的内核栈执行
        swtch(&c->context, &p->context);

        // === 进程被切换回来,继续执行调度器 ===
        // 当进程让出CPU或时间片用完时,会通过 swtch() 切回这里
        // 此时进程应该已经改变了它的状态(例如变为 RUNNABLE 或 SLEEPING)
        c->proc = 0;  // 当前CPU不再运行任何进程
      }
      
      release(&p->lock);  // 释放进程锁
    }
    
    // 如果系统中的进程数很少(只有 init 和 shell)
    // 可以让CPU进入低功耗等待状态,直到下一个中断
    if(nproc <= 2) {   // only init and sh exist
      intr_on();       // 确保中断开启(wfi 需要中断唤醒)
      asm volatile("wfi");  // Wait For Interrupt
                             // 让CPU进入休眠,直到下一个中断到来
    }
  }
}

这里还有个汇编函数,RISC v的寄存器约定是这样的,第一个参数地址在a0,第二个参数地址在a1

# ============================================
# 上下文切换 (Context Switch)
# 
# 函数原型: void swtch(struct context *old, struct context *new)
# 
# 功能: 保存当前CPU的寄存器状态到 old 指向的上下文结构
#       从 new 指向的上下文结构恢复寄存器状态
#       然后跳转到新上下文继续执行
# ============================================

.globl swtch          # 声明 swtch 为全局符号,可被C代码调用
swtch:
    # ========== 保存当前上下文到 old ==========
    # a0 寄存器指向 old context 结构体
    # 存储被调用者保存的寄存器 (Callee-saved registers)
    
    sd ra, 0(a0)      # 存储返回地址 (ra) 到 old+0
                      # ra = 调用 swtch 后的返回地址
    
    sd sp, 8(a0)      # 存储栈指针 (sp) 到 old+8
                      # sp = 当前内核栈指针
    
    sd s0, 16(a0)     # 存储 s0 (帧指针) 到 old+16
    sd s1, 24(a0)     # 存储 s1 到 old+24
    sd s2, 32(a0)     # 存储 s2 到 old+32
    sd s3, 40(a0)     # 存储 s3 到 old+40
    sd s4, 48(a0)     # 存储 s4 到 old+48
    sd s5, 56(a0)     # 存储 s5 到 old+56
    sd s6, 64(a0)     # 存储 s6 到 old+64
    sd s7, 72(a0)     # 存储 s7 到 old+72
    sd s8, 80(a0)     # 存储 s8 到 old+80
    sd s9, 88(a0)     # 存储 s9 到 old+88
    sd s10, 96(a0)    # 存储 s10 到 old+96
    sd s11, 104(a0)   # 存储 s11 到 old+104

    # ========== 从 new 恢复新上下文 ==========
    # a1 寄存器指向 new context 结构体
    # 加载被调用者保存的寄存器
    
    ld ra, 0(a1)      # 从 new+0 加载返回地址 (ra)
                      # 这个地址将成为新上下文执行时的返回地址
    
    ld sp, 8(a1)      # 从 new+8 加载栈指针 (sp)
                      # 切换到新进程/线程的内核栈
    
    ld s0, 16(a1)     # 从 new+16 加载 s0 (帧指针)
    ld s1, 24(a1)     # 从 new+24 加载 s1
    ld s2, 32(a1)     # 从 new+32 加载 s2
    ld s3, 40(a1)     # 从 new+40 加载 s3
    ld s4, 48(a1)     # 从 new+48 加载 s4
    ld s5, 56(a1)     # 从 new+56 加载 s5
    ld s6, 64(a1)     # 从 new+64 加载 s6
    ld s7, 72(a1)     # 从 new+72 加载 s7
    ld s8, 80(a1)     # 从 new+80 加载 s8
    ld s9, 88(a1)     # 从 new+88 加载 s9
    ld s10, 96(a1)    # 从 new+96 加载 s10
    ld s11, 104(a1)   # 从 new+104 加载 s11
    
    ret               # 返回到 ra 指向的地址
                      # 如果 ra 被 new 上下文覆盖了,则跳转到新进程

注意这里用的其实还是内核栈,
具体流程如下:

调度器(内核态)

swtch() 切换到进程的内核栈

【现在在进程的内核栈上执行】

forkret() → usertrapret() → sret

【切换到用户栈,进入用户态】

进程在用户态执行…

最后ret会跳转到ra指向的地址,下面的代码都是在内核栈执行的:

// ============================================
// forkret - 第一个进程或新进程第一次被调度时执行的函数
// 
// 这是进程从内核态返回用户态之前的"桥梁"函数
// 在 allocproc() 中被设置为 context.ra = forkret
// 所以进程第一次被调度时,会从这里开始执行
// ============================================
void
forkret(void)
{
  static int first = 1;  // 静态变量,记录是否是第一次调用

  // ==========================================
  // 第一步:释放进程锁
  // ==========================================
  // 重要!此时仍然持有 allocproc() 或 fork() 中获取的 p->lock
  // 在调度器调用 swtch() 切换到进程时,调度器持有这个锁
  // 所以必须在返回用户态之前释放,否则会导致死锁
  
  // 为什么在这里释放?
  // 1. allocproc() 找到空闲进程后,一直持有锁
  // 2. 调度器选择进程后,通过 swtch 切换到进程
  // 3. 进程执行到这里时,锁仍然被持有
  // 4. 必须释放,因为用户态代码不能持有内核锁
  release(&myproc()->lock);

  // ==========================================
  // 第二步:文件系统初始化(仅第一次)
  // ==========================================
  // first 是 static 变量,所以只有第一次调用时才执行
  // 这样 fsinit() 只运行一次
  if (first) {
    // 文件系统初始化必须在普通进程的上下文中运行
    // 原因:fsinit() 可能会调用 sleep() 等待磁盘I/O
    // sleep() 需要在进程上下文中执行(有进程控制块)
    // 而 main() 函数运行在引导阶段,没有普通进程上下文
    // 
    // 所以利用第一个进程(init进程)来初始化文件系统
    first = 0;               // 标记已完成
    fsinit(ROOTDEV);         // 初始化根文件系统
  }

  // ==========================================
  // 第三步:返回用户态
  // ==========================================
  // usertrapret() 会:
  // 1. 从 trapframe 恢复用户态寄存器
  // 2. 设置 sepc = 用户程序计数器
  // 3. 设置 sstatus(准备返回用户态)
  // 4. 切换到用户页表
  // 5. 执行 sret 指令,真正进入用户态
  // 
  // 对于 init 进程:epc = 0,从 initcode 开始执行
  // 对于 fork 进程:epc 继承自父进程,继续执行
  usertrapret();
}

栈的切换流程:

时间: 调度器选择进程
┌─────────────────────────────────────────────┐
│ 当前使用: 调度器栈 (c->context) │
│ 状态: 内核态 │
│ 切换: swtch(&c->context, &p->context) │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 当前使用: 进程P内核栈 (p->kstack) │
│ 状态: 内核态 │
│ 执行: forkret() → usertrapret() │
│ 准备: 设置 sepc、sstatus、用户页表 │
└─────────────────────────────────────────────┘
↓ sret
┌─────────────────────────────────────────────┐
│ 当前使用: 进程P用户栈 (p->trapframe->sp) │
│ 状态: 用户态 │
│ 执行: 用户代码 │
└─────────────────────────────────────────────┘

接下来就是进入usertrapret():

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

  // 关中断。
  // 原因:接下来要把 stvec 从 kernelvec 改成 uservec(trampoline)。
  // 如果此时发生中断,CPU 会跳到 trampoline.S 的 uservec,
  // 但此时我们还在内核态、内核页表下,uservec 假设的是用户态上下文,
  // 直接跳过去会崩溃。所以先关中断,直到 sret 真正回到用户态后再开。
  intr_off();

  // 设置 stvec,为下一次用户态的 trap(ecall/中断/异常)做准备。
  // TRAMPOLINE 是虚拟地址 0x3FFFFFF000(虚拟空间最顶端)。
  // uservec - trampoline 是 uservec 在 trampoline 页内的偏移。
  // 这样下次用户态触发 trap 时,CPU 会跳到 trampoline.S 的 uservec 标签处。
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // 下面这 4 行把内核关键信息保存到 trapframe 中。
  // 下次用户态 trap 进入 trampoline.S 的 uservec 时,
  // 它需要从 trapframe 里恢复这些信息,才能正确进入内核 C 代码。

  // 保存内核页表的 satp 值,uservec 切换页表时会用到。
  p->trapframe->kernel_satp = r_satp();

  // 保存该进程的内核栈顶指针。
  // uservec 进入内核后,需要把 sp 切到这个栈上,才能调用 C 函数。
  p->trapframe->kernel_sp = p->kstack + PGSIZE;

  // 保存 usertrap() 函数的地址。
  // trampoline.S 设置好环境后会跳到这里,进入 C 代码处理 trap。
  p->trapframe->kernel_trap = (uint64)usertrap;

  // 保存当前 CPU 的 hartid(多核编号),供 cpuid() 使用。
  p->trapframe->kernel_hartid = r_tp();

  // 下面设置 sstatus 寄存器,为 sret 指令做准备。
  // sret 会根据 sstatus 的值决定:
  //   1. 返回到什么特权级(SPP 位)
  //   2. 是否恢复中断使能(SPIE → SIE)

  unsigned long x = r_sstatus();

  // 清除 SPP 位(设成 0)。
  // SPP = 0 表示"之前是 User-mode",sret 时会切回用户态。
  // 如果不清,sret 后还在内核态,用户程序就拥有了内核权限,严重安全漏洞。
  x &= ~SSTATUS_SPP;

  // 设置 SPIE 位(Supervisor Previous Interrupt Enable)。
  // 意思是:回到用户态后,把之前保存的用户态中断使能状态恢复。
  // 这样用户程序可以正常响应中断。
  x |= SSTATUS_SPIE;

  w_sstatus(x);

  // 设置 sepc(Supervisor Exception Program Counter)。
  // sret 指令会把 pc 设成 sepc 的值。
  // 这里恢复成用户程序 trap 发生时的下一条指令地址,
  // 这样用户程序从它被打断的地方继续执行。
  w_sepc(p->trapframe->epc);

  // 构造用户页表的 satp 值(包含页表物理地址 + Sv39 模式标记)。
  // 这个值会传给 trampoline.S,由它写入 satp 寄存器,切换回用户页表。
  uint64 satp = MAKE_SATP(p->pagetable);

  // 计算 trampoline.S 中 userret 的虚拟地址。
  // TRAMPOLINE 是页起始,加上 userret 在页内的偏移。
  // 把这个地址强转成函数指针并调用。
  // 参数:TRAPFRAME(trapframe 的虚拟地址)、satp(用户页表)。
  uint64 fn = TRAMPOLINE + (userret - trampoline);

  // 跳转到 trampoline.S 的 userret!
  // 此时还在内核页表下,但 TRAMPOLINE 这一页在内核/用户页表中都映射到同一虚拟地址,
  // 所以切换页表后代码能无缝继续执行。
  // userret 会:
  //   1. 把 satp 写入,切换回用户页表
  //   2. 从 trapframe 恢复所有用户寄存器(ra, sp, a0-a7, s0-s11...)
  //   3. 执行 sret,硬件自动把特权级降到 U-mode,pc 跳到 sepc
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

然后是userret:

.globl userret
userret:
        # userret(TRAPFRAME, pagetable)
        # 从内核态返回用户态
        # usertrapret() 调用这里
        # a0: TRAPFRAME 的虚拟地址(在用户页表中)
        # a1: 用户页表的 satp 值(用于写入 satp 寄存器)

        # 切换到用户页表
        csrw satp, a1
        sfence.vma zero, zero       # 刷新 TLB,确保新页表生效

        # 把用户态的 a0(保存在 trapframe 偏移 112 处)先放到 sscratch
        # 因为最后一步要用 csrrw 原子交换来恢复 a0
        ld t0, 112(a0)              # 112 是 trapframe 中 a0 的偏移
        csrw sscratch, t0           # sscratch = 用户的 a0

        # 从 TRAPFRAME 恢复所有用户寄存器,除了 a0
        # 因为 a0 目前还指向 TRAPFRAME,不能覆盖
        ld ra, 40(a0)               # 40: ra 的偏移
        ld sp, 48(a0)               # 48: sp 的偏移
        ld gp, 56(a0)
        ld tp, 64(a0)
        ld t0, 72(a0)
        ld t1, 80(a0)
        ld t2, 88(a0)
        ld s0, 96(a0)
        ld s1, 104(a0)
        ld a1, 120(a0)              # 恢复用户的 a1(覆盖传入的 pagetable 参数)
        ld a2, 128(a0)
        ld a3, 136(a0)
        ld a4, 144(a0)
        ld a5, 152(a0)
        ld a6, 160(a0)
        ld a7, 168(a0)
        ld s2, 176(a0)
        ld s3, 184(a0)
        ld s4, 192(a0)
        ld s5, 200(a0)
        ld s6, 208(a0)
        ld s7, 216(a0)
        ld s8, 224(a0)
        ld s9, 232(a0)
        ld s10, 240(a0)
        ld s11, 248(a0)
        ld t3, 256(a0)
        ld t4, 264(a0)
        ld t5, 272(a0)
        ld t6, 280(a0)

        # 原子交换:恢复用户 a0,同时把 TRAPFRAME 地址存入 sscratch
        # 效果:a0 = 用户的 a0(之前存在 sscratch 里)
        #       sscratch = TRAPFRAME 地址(供下次用户态 trap 时使用)
        csrrw a0, sscratch, a0

        # 返回用户态!
        # sstatus 和 sepc 已经在 usertrapret() 中设置好了
        # sret 硬件自动完成:
        #   1. pc = sepc(用户程序被打断的位置)
        #   2. 特权级降到 U-mode
        #   3. 恢复用户态中断使能
        sret

这是第一个进程填写stvec的过程。
流程大概是:userinit---->allocproc( p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;)------->scheduler------->swtch------->forkret--------->usertrapret-------->userret
主要就是usertrapret中的

w_stvec(TRAMPOLINE + (uservec - trampoline));

那么下次再中断的时候,ecall的第三个作用就会发挥作用:
跳转到uservec这个函数:

.globl trampoline
trampoline:
.align 4
.globl uservec
uservec:    
	#
        # trap.c 将 stvec 寄存器设置为指向这里,
        # 因此来自用户空间的陷阱(trap)从这里开始执行,
        # 此时处于监管者模式(supervisor mode),
        # 但使用的是用户页表。
        #
        # sscratch 寄存器指向进程的 p->trapframe 结构体
        # 在用户空间映射的位置,即 TRAPFRAME。
        #
        
	# 交换 a0 和 sscratch 的值
        # 使得 a0 现在保存 TRAPFRAME 的地址
        csrrw a0, sscratch, a0

        # 将用户寄存器保存到 TRAPFRAME 中
        # 注意:偏移量对应 trapframe 结构体中各字段的位置
        sd ra, 40(a0)      # 保存返回地址 ra
        sd sp, 48(a0)      # 保存栈指针 sp
        sd gp, 56(a0)      # 保存全局指针 gp
        sd tp, 64(a0)      # 保存线程指针 tp
        sd t0, 72(a0)      # 保存临时寄存器 t0
        sd t1, 80(a0)      # 保存临时寄存器 t1
        sd t2, 88(a0)      # 保存临时寄存器 t2
        sd s0, 96(a0)      # 保存保存寄存器 s0 (frame pointer)
        sd s1, 104(a0)     # 保存保存寄存器 s1
        sd a1, 120(a0)     # 保存参数寄存器 a1 (a0 已特殊处理)
        sd a2, 128(a0)     # 保存参数寄存器 a2
        sd a3, 136(a0)     # 保存参数寄存器 a3
        sd a4, 144(a0)     # 保存参数寄存器 a4
        sd a5, 152(a0)     # 保存参数寄存器 a5
        sd a6, 160(a0)     # 保存参数寄存器 a6
        sd a7, 168(a0)     # 保存参数寄存器 a7
        sd s2, 176(a0)     # 保存保存寄存器 s2
        sd s3, 184(a0)     # 保存保存寄存器 s3
        sd s4, 192(a0)     # 保存保存寄存器 s4
        sd s5, 200(a0)     # 保存保存寄存器 s5
        sd s6, 208(a0)     # 保存保存寄存器 s6
        sd s7, 216(a0)     # 保存保存寄存器 s7
        sd s8, 224(a0)     # 保存保存寄存器 s8
        sd s9, 232(a0)     # 保存保存寄存器 s9
        sd s10, 240(a0)    # 保存保存寄存器 s10
        sd s11, 248(a0)    # 保存保存寄存器 s11
        sd t3, 256(a0)     # 保存临时寄存器 t3
        sd t4, 264(a0)     # 保存临时寄存器 t4
        sd t5, 272(a0)     # 保存临时寄存器 t5
        sd t6, 280(a0)     # 保存临时寄存器 t6

	# 保存用户的 a0 到 p->trapframe->a0
        # 因为刚才交换后,原始的 a0 值现在在 sscratch 中
        csrr t0, sscratch
        sd t0, 112(a0)     # 偏移量 112 对应 trapframe 中的 a0 字段

        # 从 p->trapframe->kernel_sp 恢复内核栈指针
        ld sp, 8(a0)       # 偏移量 8 对应 kernel_sp 字段

        # 设置 tp 为当前 hartid(硬件线程ID)
        # 从 p->trapframe->kernel_hartid 读取
        ld tp, 32(a0)      # 偏移量 32 对应 kernel_hartid 字段

        # 加载 usertrap() 函数的地址
        # 从 p->trapframe->kernel_trap 读取
        ld t0, 16(a0)      # 偏移量 16 对应 kernel_trap 字段

        # 恢复内核页表
        # 从 p->trapframe->kernel_satp 读取 satp 值
        ld t1, 0(a0)       # 偏移量 0 对应 kernel_satp 字段
        csrw satp, t1      # 写入 satp 寄存器,切换回内核页表
        sfence.vma zero, zero  # 刷新 TLB(转换后备缓冲区)

        # 此时 a0 不再有效,因为内核页表
        # 没有特殊映射 p->tf(TRAPFRAME)
        # (内核页表中 TRAPFRAME 地址对应的物理内存映射不同)

        # 跳转到 usertrap() 函数,该函数不会返回
        jr t0              # 跳转到 usertrap() 开始执行

下面是usertrap的代码:

void
usertrap(void)
{
 int which_dev = 0;

 // 安全检查:确认 trap 确实来自用户态。
 // SSTATUS_SPP 位表示进入 Supervisor 模式前的特权级:
 //   0 = 之前是 User 模式,1 = 之前是 Supervisor 模式。
 // 如果 SPP 为 1,说明内核代码有 bug,在内核态时误进了 usertrap。
 if((r_sstatus() & SSTATUS_SPP) != 0)
   panic("usertrap: not from user mode");

 // 把 stvec(trap 入口地址)改成 kernelvec。
 // 原因:我们现在已经在内核态执行 C 代码了。如果此时发生中断
 // (比如另一个硬件中断),CPU 应该直接走内核态的中断处理路径,
 // 不需要再经过 uservec 保存用户寄存器、切换页表那套流程。
 // kernelvec 会直接在内核栈上保存,处理完后 iret 回来。
 w_stvec((uint64)kernelvec);

 struct proc *p = myproc();
 
 // 保存用户态的程序计数器。
 // sepc 是硬件在进入 trap 时自动保存的 PC 值。
 // 后面返回用户态时,sret 会把 sepc 恢复到 pc。
 // 这里先存到 trapframe,因为后续处理可能会修改 sepc(比如系统调用 +4)。
 p->trapframe->epc = r_sepc();
 
 if(r_scause() == 8){
   // scause == 8 表示用户态执行了 ecall 指令(系统调用)。

   // 如果进程已被标记为 killed(比如收到了 SIGKILL 类的信号),
   // 不执行系统调用,直接退出。
   if(p->killed)
     exit(-1);

   // ecall 指令本身占 4 个字节。
   // 如果不 +4,sret 后会回到 ecall 指令本身,再次触发系统调用,死循环。
   p->trapframe->epc += 4;

   // 开中断。注意:前面一直关着中断(硬件进 trap 时自动关了 SIE)。
   // 系统调用可能执行很长时间(比如读写磁盘),允许中断可以提高并发。
   // 但必须等 sstatus/scause/sepc 这些寄存器读完了再开,
   // 否则中断处理程序会覆盖它们。
   intr_on();

   // 根据 trapframe->a7(系统调用号)分发到具体的 sys_xxx 函数。
   syscall();
 } else if((which_dev = devintr()) != 0){
   // devintr() 读取 scause,判断中断来源:
   //   which_dev == 2  ->  定时器中断
   //   which_dev == 1  ->  软件中断(如 IPI)
   //   which_dev == 3  ->  外部设备中断(PLIC)
   // 如果不是设备中断,返回 0,走下面的 else 分支。
   // ok
 } else {
   // 既不是系统调用,也不是已知设备中断。
   // 可能是缺页异常、非法指令等用户程序 bug。
   printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
   printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
   // 标记进程为 killed,稍后 exit(-1) 回收资源。
   p->killed = 1;
 }

 // 再次检查:如果进程在上面某个步骤中被标记为 killed(比如系统调用里出错),
 // 这里直接退出。exit 不会返回,会进入 sched() 把 CPU 交回调度器。
 if(p->killed)
   exit(-1);

 // 如果是定时器中断(which_dev == 2),强制让出 CPU。
 // 这就是 xv6 时间片轮转调度的核心:每个 tick(约 1ms)中断一次,
 // 当前进程从 RUNNING 变成 RUNNABLE,调度器挑下一个进程上 CPU。
 // 注意:yield() 内部会调用 sched(),sched() 会 swtch 到 scheduler(),
 // 不会立即返回。等这个进程再次被调度时,从 swtch 返回,继续执行下面的
 // usertrapret()。
 if(which_dev == 2)
   yield();

 // 返回用户态。它会:
 //   1. 把 stvec 改回 uservec(为下一次用户态 trap 做准备)
 //   2. 从 trapframe 恢复用户寄存器
 //   3. 设置 sepc = trapframe->epc
 //   4. 执行 sret 回到用户空间
 usertrapret();
}

中间如果是系统调用这个函数:

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "proc.h"
#include "syscall.h"
#include "defs.h"

// 从当前进程的用户空间地址 addr 处读取一个 uint64。
// 这是内核读取用户指针内容的安全接口。
// 返回值:0 成功,-1 失败(地址越界或非法)。
int
fetchaddr(uint64 addr, uint64 *ip)
{
  struct proc *p = myproc();
  // 安全检查:addr 必须在进程地址空间内,且 addr+8 不越界。
  // p->sz 是进程的用户态内存大小(从 0 开始)。
  if(addr >= p->sz || addr+sizeof(uint64) > p->sz)
    return -1;
  // copyin 在内核页表和用户页表之间做受控拷贝。
  // 它会把用户页表中 addr 对应的物理页内容拷贝到内核缓冲区 ip。
  if(copyin(p->pagetable, (char *)ip, addr, sizeof(*ip)) != 0)
    return -1;
  return 0;
}

// 从当前进程的用户空间地址 addr 处读取一个以 \0 结尾的字符串。
// 拷贝到内核缓冲区 buf,最多拷贝 max 字节。
// 返回值:字符串长度(不含 \0),或 -1 表示出错。
int
fetchstr(uint64 addr, char *buf, int max)
{
  struct proc *p = myproc();
  // copyinstr 会逐字节从用户空间拷贝,直到遇到 \0 或达到 max-1,
  // 然后补上 \0。它内部会做页表遍历和权限检查。
  int err = copyinstr(p->pagetable, buf, addr, max);
  if(err < 0)
    return err;
  return strlen(buf);
}

// 获取第 n 个系统调用参数(原始 uint64 值)。
// RISC-V 的 calling convention:参数通过 a0-a5 寄存器传递。
// uservec 在进入内核时把这些寄存器保存到了 p->trapframe。
// 所以这里直接从 trapframe 里取,不需要访问用户栈。
static uint64
argraw(int n)
{
  struct proc *p = myproc();
  switch (n) {
  case 0:
    return p->trapframe->a0;
  case 1:
    return p->trapframe->a1;
  case 2:
    return p->trapframe->a2;
  case 3:
    return p->trapframe->a3;
  case 4:
    return p->trapframe->a4;
  case 5:
    return p->trapframe->a5;
  }
  panic("argraw");  // 系统调用最多 6 个参数,n 超出范围说明内核有 bug
  return -1;
}

// 获取第 n 个系统调用参数,当作 int(32 位有符号整数)。
// 直接截断 argraw 的 uint64 结果。
int
argint(int n, int *ip)
{
  *ip = argraw(n);
  return 0;
}

// 获取第 n 个系统调用参数,当作指针/地址。
// 这里不做地址合法性检查,因为后续使用这个地址时
// 会调用 copyin/copyout,它们内部会做页表权限验证。
int
argaddr(int n, uint64 *ip)
{
  *ip = argraw(n);
  return 0;
}

// 获取第 n 个系统调用参数,把它当作指向用户态字符串的指针。
// 把字符串拷贝到内核缓冲区 buf,最多 max 字节。
// 返回值:字符串长度(含 \0),或 -1 出错。
int
argstr(int n, char *buf, int max)
{
  uint64 addr;
  if(argaddr(n, &addr) < 0)   // 先从 trapframe 拿到地址值
    return -1;
  return fetchstr(addr, buf, max);  // 再从用户空间读字符串内容
}

// 所有 sys_xxx 函数的前置声明。
// 它们都遵循统一签名:无参数,返回 uint64(即系统调用的返回值)。
// 参数通过 argint/argaddr/argstr 从 trapframe 间接获取。
extern uint64 sys_chdir(void);
extern uint64 sys_close(void);
extern uint64 sys_dup(void);
extern uint64 sys_exec(void);
extern uint64 sys_exit(void);
extern uint64 sys_fork(void);
extern uint64 sys_fstat(void);
extern uint64 sys_getpid(void);
extern uint64 sys_kill(void);
extern uint64 sys_link(void);
extern uint64 sys_mkdir(void);
extern uint64 sys_mknod(void);
extern uint64 sys_open(void);
extern uint64 sys_pipe(void);
extern uint64 sys_read(void);
extern uint64 sys_sbrk(void);
extern uint64 sys_sleep(void);
extern uint64 sys_unlink(void);
extern uint64 sys_wait(void);
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);

// 系统调用分发表(函数指针数组)。
// 使用 C99 的 designated initializer:[索引] = 值。
// 这样数组下标就是 SYS_xxx 枚举值,自动映射到对应的处理函数。
// 如果某个下标为 0/NULL,说明该系统调用未实现。
static uint64 (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
[SYS_exit]    sys_exit,
[SYS_wait]    sys_wait,
[SYS_pipe]    sys_pipe,
[SYS_read]    sys_read,
[SYS_kill]    sys_kill,
[SYS_exec]    sys_exec,
[SYS_fstat]   sys_fstat,
[SYS_chdir]   sys_chdir,
[SYS_dup]     sys_dup,
[SYS_getpid]  sys_getpid,
[SYS_sbrk]    sys_sbrk,
[SYS_sleep]   sys_sleep,
[SYS_uptime]  sys_uptime,
[SYS_open]    sys_open,
[SYS_write]   sys_write,
[SYS_mknod]   sys_mknod,
[SYS_unlink]  sys_unlink,
[SYS_link]    sys_link,
[SYS_mkdir]   sys_mkdir,
[SYS_close]   sys_close,
};

// 系统调用总入口。
// 由 usertrap() 在识别出 scause == 8 (ecall) 后调用。
void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  // 系统调用号放在 a7 寄存器(RISC-V ABI 规定)。
  // uservec 已经把用户寄存器保存到 trapframe,所以这里直接读。
  num = p->trapframe->a7;

  // 检查系统调用号是否合法,且对应函数存在。
  // NELEM(syscalls) 计算数组元素个数。
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // 调用对应的 sys_xxx() 函数,并把返回值写回 trapframe->a0。
    // 为什么写回 a0?因为 sret 返回用户态后,用户代码会从 a0 读返回值。
    // 这完全符合 RISC-V 的函数调用约定。
    p->trapframe->a0 = syscalls[num]();
  } else {
    // 非法系统调用号:打印调试信息,向用户返回 -1。
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

至于usertrapret和userret已经解释过了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐