Xv6 kernel source files.

File Description
bio.c Disk block cache for the file system.
console.c Connect to the user keyboard and screen.
entry.S Very first boot instructions.
exec.c exec() system call.
file.c File descriptor support.
fs.c File system.
kalloc.c Physical page allocator.
kernelvec.S Handle traps from kernel, and timer interrupts.
log.c File system logging and crash recovery.
main.c Control initialization of other modules during boot.
pipe.c Pipes.
plic.c RISC-V interrupt controller.
printf.c Formatted output to the console.
proc.c Processes and scheduling.
sleeplock.c Locks that yield the CPU.
spinlock.c Locks that don’t yield the CPU.
start.c Early machine-mode boot code.
string.c C string and byte-array library.
swtch.S Thread switching.
syscall.c Dispatch system calls to handling function.
sysfile.c File-related system calls.
sysproc.c Process-related system calls.
trampoline.S Assembly code to switch between user and kernel.
trap.c C code to handle and return from traps and interrupts.
uart.c Serial-port console device driver.
virtio_disk.c Disk device driver.
vm.c Manage page tables and address spaces.

当 RISC-V 计算机上电时,它会对自身进行初始化,并运行一个存储在只读存储器中的引导加载程序。该引导加载程序将 xv6 内核加载到内存中。随后,在机器模式(machine mode)下,CPU 从 _entry(kernel/entry.S:7)开始执行 xv6。RISC-V 启动时分页硬件处于禁用状态:虚拟地址直接映射到物理地址。 加载程序将 xv6 内核加载到物理地址 0x80000000 处的内存中。之所以将内核放置在 0x80000000 而不是 0x0,是因为地址范围 0x0:0x80000000 包含 I/O 设备。_entry 处的指令设置栈,使 xv6 能够运行 C 代码。Xv6 在文件 start.c(kernel/start.c:11)中为初始栈 stack0 声明空间。_entry 处的代码将栈指针寄存器 sp 加载为地址 stack0+4096,即栈顶,因为 RISC-V 上的栈向下增长。既然内核已经拥有栈,_entry 便调用 start(kernel/start.c:21)处的 C 代码。函数 start 执行一些仅允许在机器模式下进行的配置,然后切换到监督者模式(supervisor mode)。为进入监督者模式,RISC-V 提供了指令 mret。该指令最常用于之前从监督者模式进入了机器模式,现在从机器模式返回到之前的监督者模式。start 并不是从这样的调用返回,而是将相关状态设置得仿佛曾发生过这样一次调用:它在寄存器 mstatus 中将先前的特权模式设置为监督者模式;通过将 main 的地址写入寄存器 mepc,将返回地址设置为 main;通过向页表寄存器 satp 写入 0,在监督者模式下禁用虚拟地址转换;并将所有中断和异常委派给监督者模式。在跳转到监督者模式之前,start 还执行一项任务:对时钟芯片进行编程,使其生成定时器中断。在完成这些准备工作之后,start 通过调用 mret “返回”到监督者模式。这会使程序计数器变为 main(kernel/main.c:11)。在 main(kernel/main.c:11)初始化若干设备和子系统之后,它通过调用 userinit(kernel/proc.c:226)创建第一个进程。第一个进程执行一个用 RISC-V 汇编编写的小程序,并发起 xv6 中的第一次系统调用。initcode.S(user/initcode.S:3)将 exec 系统调用的编号 SYS_EXEC(kernel/syscall.h:8)加载到寄存器 a7 中,然后调用 ecall 以重新进入内核。内核在 syscall(kernel/syscall.c:133)中使用寄存器 a7 中的编号来调用所需的系统调用。系统调用表(kernel/syscall.c:108)将 SYS_EXEC 映射到 sys_exec,内核随后调用该函数。正如我们在第 1 章中所见,exec 会用一个新程序(在本例中为 /init)替换当前进程的内存和寄存器。一旦内核完成 exec,它便返回到 /init 进程的用户空间。Init(user/init.c:15)在需要时创建一个新的控制台设备文件,然后将其作为文件描述符 0、1 和 2 打开。随后,它在控制台上启动一个 shell。至此,系统已启动完毕。

RISC-V 基础指令集

指令集

![[6232c2d3-0890-4252-b923-09983ec2b1d7.png]]

在这里插入图片描述
在这里插入图片描述

RV32 和 RV64 的区别

RV32RV64 的核心区别是 XLEN 不同。XLEN 表示通用整数寄存器的位宽,也是多数整数运算的默认操作数宽度。

对比项 RV32 RV64
XLEN 32 位 64 位
通用寄存器宽度 x0-x31 各 32 位 x0-x31 各 64 位
地址空间 以 32 位地址空间为主 以 64 位地址空间为主
指针大小 通常为 4 字节 通常为 8 字节
long 类型大小 通常为 4 字节 通常为 8 字节
基础整数指令集 RV32I RV64I
Load/Store 支持到 lw/sw 增加 ld/sd 等 64 位访存指令
32 位运算 默认整数运算即为 32 位 使用带 w 后缀的 word 指令

寄存器宽度

RV32 的通用寄存器宽度为 32 位,RV64 的通用寄存器宽度为 64 位。因此,同一条整数运算指令在两种架构中的默认操作数宽度不同。

add a0, a1, a2

RV32 中,该指令执行 32 位加法;在 RV64 中,该指令执行 64 位加法。

指针和地址宽度

RV32 通常使用 32 位地址空间,指针大小为 4 字节;RV64 通常使用 64 位地址空间,指针大小为 8 字节。该差异会影响 C 程序的数据结构布局。

struct node {
  int value;
  struct node *next;
};

RV32 中,next 指针通常占 4 字节;在 RV64 中,next 指针通常占 8 字节。因此,相同结构体在 RV64 上可能占用更多内存。

Load/Store 指令

RV64RV32 的基础上增加了 64 位访存指令。

指令 RV32 RV64 含义
lb 支持 支持 读取 8 位并符号扩展
lbu 支持 支持 读取 8 位并零扩展
lh 支持 支持 读取 16 位并符号扩展
lhu 支持 支持 读取 16 位并零扩展
lw 支持 支持 读取 32 位;在 RV64 中符号扩展到 64 位
lwu 不支持 支持 读取 32 位并零扩展到 64 位
ld 不支持 支持 读取 64 位
sb 支持 支持 写入低 8 位
sh 支持 支持 写入低 16 位
sw 支持 支持 写入低 32 位
sd 不支持 支持 写入 64 位

RV64 中,保存返回地址 ra 到栈上通常使用 sd

sd ra, 8(sp)

RV32 中,保存返回地址通常使用 sw

sw ra, 4(sp)

Word 指令

RV64 的普通整数运算默认处理 64 位数据。若需要执行 32 位整数运算,应使用带 w 后缀的 word 指令。w 表示 32 位 word。

指令 含义
addw rd, rs1, rs2 对低 32 位执行加法,结果符号扩展到 64 位
subw rd, rs1, rs2 对低 32 位执行减法,结果符号扩展到 64 位
addiw rd, rs1, imm 对低 32 位执行立即数加法,结果符号扩展到 64 位
sllw rd, rs1, rs2 对低 32 位执行逻辑左移,结果符号扩展到 64 位
srlw rd, rs1, rs2 对低 32 位执行逻辑右移,结果符号扩展到 64 位
sraw rd, rs1, rs2 对低 32 位执行算术右移,结果符号扩展到 64 位
addw a0, a1, a2

该指令只对 a1a2 的低 32 位执行加法,并将 32 位结果符号扩展到 64 位后写入 a0。这类指令主要用于支持 C 语言中的 32 位 int 运算。

指令编码

RV32IRV64I 的基础指令编码格式基本一致,包括:

  • R-type
  • I-type
  • S-type
  • B-type
  • U-type
  • J-type

差异主要体现在操作数位宽、部分新增指令以及移位量范围上。例如,ldsdlwuaddw 等指令只存在于 RV64I 中。

移位指令

由于寄存器宽度不同,移位量范围也不同。

指令类型 RV32 RV64
普通移位 移位量通常取低 5 位,范围为 0-31 移位量通常取低 6 位,范围为 0-63
word 移位 默认即为 32 位移位 使用 sllwsrlwsraw 等 word 指令

RV64 中的 64 位移位可以使用更大的移位量:

slli a0, a0, 63

对 xv6 的影响

xv6-riscv 使用 RV64,因此阅读 xv6 汇编代码时需要注意以下特征:

现象 原因
经常出现 ld/sd 保存和恢复 64 位寄存器
栈上保存一个寄存器通常占 8 字节 通用寄存器宽度为 64 位
指针大小为 8 字节 内核运行在 64 位 RISC-V 上
页表项 PTE 为 64 位 xv6-riscv 使用 64 位地址相关机制
satpsepcstvec 等 CSR 保存地址或状态 与 64 位特权架构相关

RV64 中常见的函数序言:

addi sp, sp, -16
sd   ra, 8(sp)
sd   s0, 0(sp)

对应的 RV32 写法通常为:

addi sp, sp, -8
sw   ra, 4(sp)
sw   s0, 0(sp)

小结

RV32 是 32 位 RISC-V,寄存器、指针和默认整数运算以 32 位为主;RV64 是 64 位 RISC-V,寄存器、指针和默认整数运算以 64 位为主,并增加了 64 位访存指令和 32 位 word 运算指令。

阅读 xv6-riscv 时需要重点记住:

  1. xv6-riscv 使用 RV64
  2. 通用寄存器为 64 位;
  3. 指针大小为 8 字节;
  4. 保存和恢复寄存器通常使用 sd/ld
  5. w 后缀的指令表示在 64 位机器上执行 32 位运算。

RISC-V常见的伪指令

li a1, 1
la a0, .string
call printf
ret
mv t0, a0
j label
beqz rs, label
  • li rd, imm:li 是 load immediate,把立即数装入寄存器rd。
  • la rd, symbol:la 是 load address,加载某个标签或全局符号的地址到寄存器rd。
  • call printf : 会跳到 printf,同时把返回地址保存到 ra,等 printf 执行完后,它会通过 ret 回来,调用 printf 之前要先保存 ra
  • ret:函数返回,展开成 jalr zero, 0(ra),跳转到ra寄存器保存的值 + 0的位置,zero = PC+4(zero只读,这句无意义)。
  • mv rd, rs:mv 是 move,把一个寄存器的值复制到另一个寄存器。
  • j label:无条件跳转,展开成jal zero, loop,jal 本来会保存返回地址,但这里目标寄存器是 zero,所以返回地址被丢弃,效果就是单纯跳转。
  • beqz rs, label:如果 rs == 0,跳转到 label。

RISC-V 内联汇编

RISC-V 内联汇编指在 C 代码中直接嵌入汇编指令。xv6-riscv 中常用它访问普通 C 无法直接访问的硬件状态,例如 CSR、特权指令、栈指针、hart id 等。

内联汇编适合处理以下场景:

  • 读写 CSR,例如 sstatussatpsepcstvec
  • 执行特权指令,例如 sfence.vmawfi
  • 访问特殊寄存器,例如 sptp
  • 实现极短且必须精确控制指令的底层代码。

不适合使用内联汇编的场景:

  • 普通算术、逻辑运算;
  • 普通内存复制或循环优化;
  • 可以由 C 编译器稳定生成的常规代码。

原因是内联汇编会限制编译器优化,并且需要手动告诉编译器哪些寄存器、内存或状态被修改。

基本语法

GNU C 风格的扩展内联汇编格式如下:

asm volatile (
  "assembly template"
  : output operands
  : input operands
  : clobbers
);

完整结构可以理解为:

asm volatile(
  "汇编指令模板"
  : 输出操作数
  : 输入操作数
  : 被破坏的寄存器或状态
);

各部分含义如下:

部分 作用
asm 表示嵌入汇编代码
volatile 告诉编译器该汇编有副作用,不能随意删除或合并
汇编模板 实际要生成的汇编指令
输出操作数 汇编代码写回到 C 变量的结果
输入操作数 C 变量传入汇编代码的值
clobbers 告诉编译器汇编代码额外修改了哪些寄存器、内存或状态

其中 volatile 主要用于有副作用的指令,例如读写 CSR、执行 sfence.vmawfiecall 等。如果汇编只是根据输入计算输出,且输出被 C 代码使用,则不一定需要 volatile

操作数占位符

内联汇编模板中的 %0%1%2 表示操作数占位符。编号从输出操作数开始,然后继续编号输入操作数。

uint64 out;
uint64 a = 1;
uint64 b = 2;

asm volatile(
  "add %0, %1, %2"
  : "=r"(out)
  : "r"(a), "r"(b)
);

操作数对应关系如下:

占位符 对应 C 表达式 含义
%0 out 输出操作数
%1 a 第一个输入操作数
%2 b 第二个输入操作数

编译器会为这些操作数分配实际寄存器。最终生成的指令可能类似:

add a5, a0, a1

因此,内联汇编中通常不应手动固定使用 t0t1 等寄存器,除非同时在 clobber list 中声明它们被修改。

常见约束

约束用于告诉编译器某个操作数应该放在哪里,以及该操作数是读、写还是读写。

约束 含义
"r"(x) x 放入通用寄存器,作为输入
"=r"(x) 将结果写入通用寄存器,再写回 x= 表示只写
"+r"(x) x 既作为输入又作为输出
"i"(imm) 编译期立即数
"I"(imm) RISC-V I-type 12 位有符号立即数
"K"(imm) RISC-V CSR 指令使用的 5 位无符号立即数
"memory" 告诉编译器该汇编可能读写未显式列出的内存,使编译器不能错误复用、合并或移动相关内存访问

示例:

asm volatile("addi %0, %1, 1" : "=r"(out) : "r"(in));

含义:

  • %0 是输出寄存器;
  • %1 是输入寄存器;
  • 编译器负责选择具体寄存器;
  • 汇编执行后,输出寄存器的值写回 C 变量 out

volatilememory 的区别

volatilememory clobber 解决的问题不同。

写法 作用
asm volatile(...) 防止该汇编语句被删除或与相同语句合并
::: "memory" 告诉编译器该汇编可能读写某些未显式列在输入/输出操作数中的内存

"memory" 是给编译器看的约束,不是 RISC-V 指令。它不会生成硬件内存屏障,也不表示所有寄存器都会被保存或重新读取。

更准确地说,"memory" 会使编译器在该 asm 前后重新考虑内存状态:

  • asm 前已经写入、且可能被 asm 观察到的内存,不能被错误移动到 asm 之后;
  • asm 后需要读取、且可能被 asm 修改过的内存,不能简单复用 asm 之前缓存的旧值;
  • 只保存在寄存器中的普通局部变量不受 "memory" 影响,除非它对应的内存对象需要在 asm 前后保持一致;
  • 如果 asm 会修改某个寄存器,应使用 clobber list 显式声明该寄存器,而不是依赖 "memory"

例如:

asm volatile("fence rw, rw" ::: "memory");

这里有两层含义:

  1. fence rw, rw 约束 CPU 层面的内存访问顺序;
  2. "memory" 告诉编译器该 asm 可能影响内存,避免编译器错误地跨越该语句复用、合并或移动相关内存访问。

因此,volatile"memory"fence 分别处理不同层面的问题:

机制 作用层面 主要作用
volatile 编译器 防止 asm 被删除或合并
"memory" 编译器 告知 asm 可能读写未知内存,约束相关内存优化
fence CPU/硬件 约束硬件层面的内存访问顺序

读改写操作数

如果一个 C 变量既作为输入又作为输出,应使用 +r

uint64 x;

asm volatile(
  "addi %0, %0, 1"
  : "+r"(x)
);

含义:

  • 汇编执行前,x 的值被放入 %0
  • 汇编执行后,%0 的值写回 x
  • + 表示该操作数既读又写。

如果错误地写成 =r,编译器会认为该操作数只写不读,从而可能生成错误代码。

常见错误

错误 后果
有副作用的汇编不写 volatile 可能被编译器删除或移动
asm 读写未显式列出的内存但不写 "memory" 编译器可能错误复用、合并或移动相关内存访问
手动使用 t0t1 等寄存器但不声明 clobber 可能破坏编译器保存的 C 变量
把读写操作数写成只写约束 =r 编译器可能认为原值无用,导致错误代码
在内联汇编中随意修改 spra 可能破坏函数调用栈或返回地址
在普通用户态代码中执行特权指令 可能触发非法指令异常或 trap
在汇编中跳转到 C 标签但不用 asm goto 编译器无法正确理解控制流

RISCV内嵌汇编代码与宏结合

内嵌汇编代码与C语言宏可以结合使用,让代码变得更简洁。我们可以巧妙地使用C语言宏中的#以及##符号:

  • 若在宏的参数前面添加#,预处理器会把这个参数转换为一个字符串。
  • ##用于连接参数和另一个标识符,形成新的标识符。
    下面代码所示为ATOMIC_OP宏,它在Linux 5.15内核里实现,代码路径为arch/ riscv/include/asm/atomic.h
    通过调用ATOMIC_OP宏实现了多个函数,如atomic_add()函数、atomic_or()函数、atomic_xor()函数等。
    使用##把atomic_与宏的参数op拼接在一起,构成函数名;使用#把参数asm_op转换成一个字符串。例如,假设asm_op参数为add,asm_tp参数为w那就变成amoadd.w zero, %1, %0\n
#define ATOMIC_OP(op, asm_op, I, asm_type, c_type, prefix) \ 
static __always_inline \ 
void atomic##prefix##_##op(c_type i, atomic##prefix##_t *v) \
 { \
	  __asm__ __volatile__ ( \ 
		  "amo" #asm_op "." #asm_type " zero, %1, %0" \ 
		  : "+A" (v->counter) \ 
		  : "r" (I) \ 
		  : "memory"); \ 
  } \ 
  
  #define ATOMIC_OPS(op, asm_op, I) \ 
		  ATOMIC_OP (op, asm_op, I, w, int, ) \

  ATOMIC_OPS(add, add, i) 
  ATOMIC_OPS(sub, add, -i) 
  ATOMIC_OPS(and, and, i) 
  ATOMIC_OPS( or, or, i) 
  ATOMIC_OPS(xor, xor, i)

ATOMIC_OPS(add, add, i) 展开后变成如下代码

static __always_inline  

void atomic_add(int i, atomic_t *v)
{                                  

  __asm__ __volatile__ (  
      "amoadd.w zero, %1, %0"  
      : "+A" (v->counter)  
      : "r" (i)      
      : "memory");  

} 

trapframe

trapframe是用户态 → 内核态切换时的「寄存器快照」+「路径地图」,它的本质是一个 「用户↔内核边界的上下文缓存」

  • 保存区:用户态被中断时的完整 CPU 状态
  • 路径地图:内核回来的路标(satp / sp / trap / hartid)
  • 隔离边界:只存在于用户页表映射中,内核页表不可见,确保用户态无法直接读写内核内存

物理布局上,trapframe 独占一页内存,紧贴在 trampoline page 下面。这个地址通过 RISC-V 的 CSR 寄存器(系统控制和状态寄存器,Control and Status Register , CSR)sscratch 指向。

XV6 在进入用户态之前,会把当前进程的 trapframe 地址提前放进 RISC-V 的 sscratch 寄存器里。等用户程序发生系统调用、中断或异常时,trampoline.S 就能从 sscratch 里拿到 trapframe 地址,把用户寄存器保存进去。

trapframe 这页内存映射在用户页表里,但没有 PTE_U 权限,所以用户态代码不能访问;
内核页表中没有把它映射到 TRAPFRAME 这个虚拟地址;
trampoline.S 在刚进入内核、还没切换到内核页表之前,借助用户页表中的 TRAPFRAME 映射来访问它。

用户态运行
    ↓ ecall / 中断
进入 supervisor mode
    ↓
仍然使用用户页表
    ↓
跳到 trampoline.S:uservec
    ↓
uservec 访问 TRAPFRAME,保存寄存器
    ↓
再切换到内核页表

源码来自xv6-labs-2021/kernel/proc.h


// per-process data for the trap handling code in trampoline.S.
// sits in a page by itself just under the trampoline page in the
// user page table. not specially mapped in the kernel page table.
// the sscratch register points here.
// uservec in trampoline.S saves user registers in the trapframe,
// then initializes registers from the trapframe's
// kernel_sp, kernel_hartid, kernel_satp, and jumps to kernel_trap.
// usertrapret() and userret in trampoline.S set up
// the trapframe's kernel_*, restore user registers from the
// trapframe, switch to the user page table, and enter user space.
// the trapframe includes callee-saved user registers like s0-s11 because the
// return-to-user path via usertrapret() doesn't return through
// the entire kernel call stack.

/*
	trapframe 是每个进程独有的数据结构,主要供 trampoline.S 中的 trap 处理代码使用。它单独占用一个页面,位于用户页表中 trampoline 页面正下方,但并不会以特殊方式映射到内核页表中。在用户态运行时,RISC-V 的 sscratch 寄存器会指向当前进程的 trapframe。当用户进程发生系统调用、中断或异常并进入内核时,trampoline.S 中的 uservec 会先把用户态寄存器保存到 trapframe 中,然后从 trapframe 的 kernel_sp、kernel_hartid、kernel_satp 等字段中取出内核栈、CPU 编号和内核页表等信息,完成运行环境切换,并跳转到 kernel_trap,也就是内核中的 usertrap() 函数进行处理。内核处理完成后,usertrapret() 和 trampoline.S 中的 userret 会重新设置 trapframe 中的 kernel_* 字段,从 trapframe 恢复用户寄存器,切换回用户页表,并通过 sret 返回用户空间。普通 C 函数调用,s0-s11 由被调用者保存到栈上,调用链返回时自然恢复。但从用户态 trap 进内核再返回,不是沿着调用链一层层返回到用户态——而是usertrapret() 直接跳到 trampoline 的 userret,然后一个 sret 飞回用户态,所以即使是 s0-s11 这类通常由被调用者保存的寄存器,也必须保存在 trapframe 中。
	
*/

struct trapframe {

/* 0 */ uint64 kernel_satp; // kernel page table 内核页表根地址
/* 8 */ uint64 kernel_sp; // top of process's kernel stack 本进程内核栈栈顶
/* 16 */ uint64 kernel_trap; // usertrap() usertrap() 函数地址
/* 24 */ uint64 epc; // saved user program counter 被中断的用户指令地址
/* 32 */ uint64 kernel_hartid; // saved kernel tp 当前 CPU 核 ID
/* 40 */ uint64 ra; //x1 返回地址
/* 48 */ uint64 sp; //x2 栈指针
/* 56 */ uint64 gp; //x3 全局指针
/* 64 */ uint64 tp; //x4 线程指针
/* 72 */ uint64 t0; //t0-t2 x5-x7 临时寄存器(不需要被调用者保存)
/* 80 */ uint64 t1; 
/* 88 */ uint64 t2;
/* 96 */ uint64 s0; //s0-s1 x8-x9 被调用者保存/帧指针
/* 104 */ uint64 s1;
/* 112 */ uint64 a0;//a0-a7 x10-x17 函数参数/返回值
/* 120 */ uint64 a1;
/* 128 */ uint64 a2;
/* 136 */ uint64 a3;
/* 144 */ uint64 a4;
/* 152 */ uint64 a5;
/* 160 */ uint64 a6;
/* 168 */ uint64 a7;
/* 176 */ uint64 s2;//s2-s11 x18-x27 被调用者(callee)保存
/* 184 */ uint64 s3;
/* 192 */ uint64 s4;
/* 200 */ uint64 s5;
/* 208 */ uint64 s6;
/* 216 */ uint64 s7;
/* 224 */ uint64 s8;
/* 232 */ uint64 s9;
/* 240 */ uint64 s10;
/* 248 */ uint64 s11;
/* 256 */ uint64 t3;//t3-t6 x28-x31 临时寄存器
/* 264 */ uint64 t4;
/* 272 */ uint64 t5;
/* 280 */ uint64 t6;

};

进程状态枚举

enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
状态 含义与理解
UNUSED 槽位空闲。XV6 的进程数组 proc[NPROC] 大小固定,初始多数槽位处于此状态。allocproc() 从这里分配。
USED 已分配、正在初始化。allocproc() 找到 UNUSED 槽位后置为 USED,此时尚不具备运行条件(页表、上下文等还在准备)。
SLEEPING 睡眠/阻塞。进程因等待某事件主动放弃 CPU——磁盘 I/O、管道数据、子进程退出等。通过 sleep(chan) 挂到等待通道上,事件发生后 wakeup(chan) 唤醒,状态转为 RUNNABLE
RUNNABLE 就绪,具备运行条件但还未被调度。scheduler() 不断扫描进程表寻找此状态的进程——「排队等 CPU」。
RUNNING 正在某个 CPU 上执行。scheduler() 选中 RUNNABLE 后置为 RUNNING,通过 swtch() 切入。每个核同时最多一个 RUNNING 进程。
ZOMBIE 僵尸状态。进程已调用 exit() 退出,但父进程尚未 wait() 回收。内核保留其 pid 和退出状态供父进程查询。父进程 wait() 后释放槽位,状态回到 UNUSED

系统调用的添加与执行

结合 SYS_trace,可以把 xv6 的系统调用流程分成两条线:

第一条线:编译时,怎么让用户程序能调用 trace()
第二条线:运行时,trace() 是怎么从用户态进入内核态并被执行的

在这里插入图片描述

如上面的绘图文件所示,用户程序调用的是用户态的 trace() 存根,真正的内核实现是 sys_trace(),二者通过系统调用号 SYS_traceecall 连接起来。

具体流程如下:

第一步:在 kernel/syscall.h 里添加系统调用号,#define SYS_trace 22,给 trace 系统调用分配一个唯一编号。
第二步:在 user/user.h 里声明用户态函数添加:int trace(int);这一步是给用户程序看的。
第三步:在 user/usys.pl 里添加用户态存根

user/usys.pl 是一个 Perl 脚本,Makefile 会调用它生成 user/usys.S。用户程序调用:trace(32),实际上会跳到用户态汇编函数:

trace:  
	li a7, SYS_trace
	ecall  
	ret

其中:li a7, SYS_trace,表示把系统调用号放入 a7 寄存器,在 xv6 RISC-V 里,系统调用约定是:

a0 ~ a5:系统调用参数
a7     :系统调用号
a0     :系统调用返回值
第四步:ecall 触发 trap

用户态执行 ecall 后,CPU 会发生 trap:

User mode    
	↓  ecall
Supervisor mode

但是 CPU 不会直接跳到 sys_trace(),它会先进入 xv6 的 trap 入口,也就是 trampoline.S 中的 uservecuservec来自于stvec (Supervisor Trap Vector Base Address Register),在 xv6 中,用户态执行 ecall 后,硬件会做几件事情

1. 记录 trap 原因到 scause
2. 记录当前用户 PC 到 sepc
3. 记录当前特权级到 sstatus.SPP
4. 关闭中断
5. 把特权级从 User mode 切到 Supervisor mode
6. 把 PC 设置为 stvec 中保存的地址

这里提及的uservec就是stvec寄存器里保存的地址。

第五步:trampoline.S 保存用户态现场

发生系统调用时,用户程序的寄存器里有很多重要内容:

a0 = trace 的参数 32
a7 = 系统调用号 SYS_trace
sp = 用户栈指针
ra = 用户函数返回地址
其他寄存器 = 用户程序当前状态

内核不能直接覆盖这些寄存器,否则系统调用返回后用户程序就乱了。所以 trampoline.S:uservec 会把用户寄存器保存到当前进程的:p->trapframe里面。

其中很关键的是:

p->trapframe->a0 = 32;
p->trapframe->a7 = SYS_trace;

之后,uservec 会切换到内核页表、内核栈,然后跳转到:usertrap()

第六步:进入 kernel/trap.c:usertrap()

usertrap() 是用户态 trap 进入内核后的 C 语言处理函数。它会判断这次 trap 是什么原因。

如果是系统调用,大致逻辑是:

if(r_scause() == 8){  
	// system call  
	if(killed(p))
	    exit(-1);  
	p->trapframe->epc += 4;
    intr_on();  
    syscall();
}

这里有几个关键点。

1. r_scause() == 8

在 RISC-V 中,用户态执行 ecall 后,scause 会记录 trap 原因。8 表示:Environment call from U-mode 也就是用户态系统调用。


2. p->trapframe->epc += 4

epc 保存的是用户程序发生 trap 时的 PC,也就是 ecall 这条指令的位置。如果不加 4,系统调用返回用户态后会再次执行同一条 ecall,然后又进入内核,造成死循环。所以:p->trapframe->epc += 4;系统调用返回后,从 ecall 的下一条指令继续执行。


之后就是调用 syscall()

第七步:kernel/syscall.c:syscall() 根据编号分发

syscall() 会先取出系统调用号:num = p->trapframe->a7;这里的 a7 是用户态存根设置的:li a7, SYS_trace,所以此时:num = SYS_trace,然后 syscall() 会查系统调用表:

//提前声明这个函数,不然会报错
extern uint64 sys_trace(void);

static uint64 (*syscalls[])(void) = {
  [SYS_fork]  sys_fork,  
  [SYS_exit]  sys_exit,  
  [SYS_wait]  sys_wait,  
  ...  
  [SYS_trace] sys_trace,
};

//调用和系统调用号对应的系统调用函数
p->trapframe->a0 = syscalls[num]();

因此,要在这里添加和SYS_trace对应的系统调用函数sys_trace,使得。

第八步:实现 kernel/sysproc.c:sys_trace()

kernel/sysproc.c 中实现具体的函数:

uint64 sys_trace(void){  
	int mask;  
	argint(0, &mask);  
	myproc()->tracemask = mask;  
	return 0;
}

这个函数是真正的内核实现。

第九步:系统调用返回值放到 a0

在 xv6 中,系统调用返回值也是通过 a0 传回用户态的。所以 syscall() 会做:p->trapframe->a0 = syscalls[num]();

对于 trace 来说,sys_trace() 返回 0,所以:p->trapframe->a0 = 0;等返回用户态后,用户程序里的trace(32),就会得到返回值 0
如果是 read(),那么 a0 里就是实际读取的字节数。
如果是 fork(),那么 a0 里就是子进程 pid 或 0。

第十步:回到用户态

syscall() 执行完后,会回到 usertrap(),然后走:usertrapret();

usertrapret() 会准备返回用户态所需的信息,例如:

设置 stvec 回到 trampoline中的uservec
设置 sepc 为 trapframe->epc
设置 sstatus,使 sret 返回 User mode
设置 trapframe 中的 kernel_satp、kernel_sp、kernel_trap 等

最后通过 trampoline.S:userret

恢复用户寄存器
切换回用户页表
执行 sret
返回用户空间

用户程序继续从 ecall 后面的指令运行。

Logo

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

更多推荐