操作系统设计实践
2018年以前的是x86平台,2019年开始改为risc-v平台。
https://pdos.csail.mit.edu/6.828/2025/xv6.html
环境搭建
ubuntu24.04虚拟机
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
源码结构
1. kernel/ 目录 - 内核核心代码
这是xv6操作系统的内核部分,包含所有核心功能:
进程管理:
proc.c, proc.h - 进程控制块(PCB)和进程管理
exec.c - 程序执行和加载
swtch.S - 进程上下文切换的汇编代码
内存管理:
kalloc.c - 内核内存分配器
vm.c, vm.h - 虚拟内存管理
memlayout.h - 内存布局定义
文件系统:
file.c, file.h - 文件操作接口
fs.c, fs.h - 文件系统实现
log.c - 文件系统日志和崩溃恢复
bio.c, buf.h - 块设备I/O和缓冲区管理
系统调用:
syscall.c, syscall.h - 系统调用分发器
sysfile.c - 文件相关系统调用实现
sysproc.c - 进程相关系统调用实现
硬件抽象层:
trap.c - 中断和异常处理
uart.c - 串口通信
virtio_disk.c, virtio.h - 磁盘I/O
plic.c - 平台级中断控制器
同步机制:
spinlock.c, spinlock.h - 自旋锁实现
sleeplock.c, sleeplock.h - 睡眠锁实现
其他核心组件:
main.c - 内核启动入口
entry.S - 内核入口汇编代码
start.c - 系统启动初始化
console.c - 控制台I/O
printf.c - 格式化输出
string.c - 字符串操作函数
2. user/ 目录 - 用户空间程序
包含所有用户态程序和工具:
系统工具:
cat.c - 文件内容显示
echo.c - 回显命令
grep.c - 文本搜索
ls.c - 目录列表
mkdir.c - 创建目录
rm.c - 删除文件
ln.c - 创建链接
wc.c - 字符/词/行计数
sleep.c - 休眠命令
kill.c - 终止进程
Shell和系统:
sh.c - Shell解释器
init.c - 系统初始化进程
memdump.c - 内存转储工具
测试程序:
usertests.c - 用户态测试套件
forktest.c - 进程创建测试
grind.c - 压力测试
stressfs.c - 文件系统压力测试
logstress.c - 日志压力测试
库函数:
ulib.c - 用户态库函数
umalloc.c - 用户态内存分配
printf.c - 用户态格式化输出
user.h - 用户态头文件定义
系统调用接口:
usys.pl - 生成系统调用汇编代码的Perl脚本
user.ld - 用户程序链接脚本
3. mkfs/ 目录 - 文件系统工具
- mkfs.c - 创建xv6文件系统镜像的工具
4. conf/ 目录 - 配置文件
- lab.mk - 实验配置的Makefile片段
5. 根目录文件
Makefile - 主构建文件
README - 项目说明文档
LICENSE - 许可证文件
test-xv6.py - 自动化测试脚本
grade-lab-util - 实验评分脚本
gradelib.py - 评分库
Lab1:Unix utilities
1.添加sleep系统调用
- kernel/syscall.h:新增系统调用号,添加 #define SYS_sleep
(选择未占用的编号,保持与现有顺序一致) - kernel/syscall.c:注册分发入口,声明 extern uint64 sys_sleep(void);,在 static uint64 (*syscalls[])(void) 表中加入 [SYS_sleep] = sys_sleep(修改:把SYS_sleep映射到sys_pause)
- kernel/sysproc.c:实现内核侧处理函数,实现 uint64 sys_sleep(void):,用 argint(0, &n) 取参数,读取并比较 ticks,在 tickslock 下调用 sleep(&ticks, &tickslock) 等待,处理中途被 killed 的情况,返回 -1 或 0,提醒:sleep(void *chan, struct spinlock *lk) 在 kernel/proc.c 已实现,可直接复用
- user/user.h:声明用户态原型,添加 int sleep(int);
- user/usys.pl:生成用户态封装(a0 放参数,a7 放号,执行 ecall)
- user/usys.S:手动新增 sleep 的封装(通常不需要,make 会由 usys.pl 自动生成),(无需修改)陷阱/返回路径
- kernel/trap.c、kernel/trampoline.S 无需改动,sleep 走标准系统调用路径即可
2.添加sixfive命令
在user/sixfive.c写sixfive函数,修改Makefile,添加到 UPROGS
思路:
打开指定的文件。
从文件中逐字符读取,提取数字(支持负数)。
用 - \r \t \n . / ,
作为数字分隔符。
对于提取到的每个数字,计算其绝对值。
如果能被 5 或 6 整除,就输出这个绝对值(每行一个)。
1 |
|
3.memdump
在user/memdump.c写memedump函数,修改Makefile添加到 UPROGS
1 |
|
4.find
- 打开与获取文件类型
用 open(path, O_RDONLY) 打开路径,失败就报错返回。
用 fstat(fd, &st) 获取该路径对应的 struct stat,以判断类型(文件/设备/目录)。
- 处理“文件/设备”节点:只比较最后一段名字
如果是 T_FILE 或 T_DEVICE,取出 path 的最后一段名字(从末尾往前找到最后一个 / 后的子串)。
若该名字与 target 相等,打印整条 path。
- 处理目录:遍历目录项并递归
先检查拼接子路径是否会超过临时缓冲区 char buf[512] 的容量:strlen(path) + 1 + DIRSIZ + 1。
将 path 复制到 buf,在末尾加上 ‘/‘,指针 p 指向可写入子名的位置。
通过 read(fd, &de, sizeof(de)) 逐条读取目录项 struct dirent de:
跳过 de.inum == 0 的空项。
用 memmove(p, de.name, DIRSIZ); p[DIRSIZ]=0; 把该目录项的名字接到 buf 后,形成形如 buf = “
/ “ 的完整路径。 跳过名字为 “.” 和 “..”(避免无限递归)。
调用 stat(buf, &st) 获取该子路径的类型:
若是目录,递归调用 find(buf, target)。
若是文件/设备,直接比较当前名字 p 是否等于 target,相等则打印 buf。
- 边界和健壮性
路径过长时打印 “find: path too long”。
open/fstat/stat 失败时打印错误并跳过。
每个 open 最后都 close(fd),避免资源泄露。
为什么要跳过 “.” 和 “..”,“.” 指向当前目录,“..” 指向父目录;如果递归进入它们会导致死循环(在当前目录与父目录之间来回)。
5.exec
find.c添加:
- 参数组装:
- 将 -exec 后的命令及其参数复制到 argv。
- 把“匹配到的完整文件路径”作为最后一个参数自动追加到 argv,并以 0 结尾。
- 有 MAXARG 限制,若命令参数太多会报错并跳过执行。
- 执行模型:
- fork() 子进程,子进程 exec(argv[0], argv) 运行命令。
- 父进程 wait(0) 等待,故对每个匹配都是顺序、同步执行(不并行)。
- 失败处理:
- fork 失败打印错误并返回。
- exec 失败在子进程内打印错误并 exit(1)。
可选:
添加uptime系统调用
创建user/uptime.c
测试结果
Lab2:System call
1.gdb调试
一个终端:make qemu-gdb
另一个终端:gdb-multiarch -nx kernel/kernel
target remote localhost:26000
b syscall
c
layout src
backtrace
执行完struct proc *p = myproc();
这个结构体是 xv6 中的 进程控制块(PCB),对应 kernel/proc.h
里的 struct proc
p->trapframe->a7的值是 用户态代码在 ecall
前放到 a7 的系统调用号 user/init.asm
易知若trap是来自用户模式则sstatus中的SPP位为0,若trap来自监管者模式则sstatus中的SPP位为1。
此时的sstatus为22其2进制表示为000101100001 011000010110,第8位SSP位为0,故CPU以前的模式是用户模式。
在 syscall
的开头将语句 num = p->trapframe->a7;
替换为 num = * (int *) 0;
,运行 make qemu
出现内核崩溃
找到kernel/kernel.S中spec对应的汇编指令
num代表系统调用号,因此变量 num
对应a7寄存器
确定了导致内核崩溃的汇编指令,num = * (int *) 0,一旦执行这条指令,CPU 就尝试访问虚拟地址 0,结果触发page fault→ 内核崩溃
2.沙盒
在 syscall.h 中添加 SYS_interpose 定义,在 proc.h 中添加沙盒掩码字段,在 syscall.c 中注册 interpose 系统调用,修改系统调用分发逻辑,添加沙盒检查,在sysproc.c实现 sys_interpose 函数,在 defs.h 中添加 sys_interpose 的声明,proc.c的在 kfork 函数中添加沙盒掩码的继承,在 allocproc 中初始化沙盒掩码,在usys.pl添加用户态接口,在user.h添加函数声明
在 RISC-V 架构里,寄存器的 ABI 约定是:
- a0 ~ a7 → 用作 函数参数寄存器
- a0, a1 → 还用来保存 函数返回值(如果返回值超过 1 个寄存器,就用 a0 + a1 传)
在 xv6 的系统调用处理中:
用户态进程发起系统调用时,调用号放在 a7 里,参数放在 a0 ~ a5。
- 这是和 RISC-V 的 syscall 约定一致的。
- 比如
write(fd, buf, n)
,a0=fd
,a1=buf
,a2=n
。
进入内核 trap 后,内核在
syscall()
里通过1
num = p->trapframe->a7;
拿到系统调用号。
内核调用对应的
syscalls[num]()
函数。- 这些函数会返回一个
int
或uint64
,作为系统调用的返回值。
- 这些函数会返回一个
返回值被写回到
1
p->trapframe->a0 = syscalls[num]();
也就是说:a0 用来保存系统调用的返回,在返回用户态时,用户进程能在 a0 中读到。
3.添加path参数
进程结构体扩展
在 kernel/proc.h 中添加了:
1 | char allowed_path[MAXPATH]; // Allowed path for open/exec system calls |
系统调用实现更新
修改了 kernel/sysproc.c 中的 sys_interpose() 函数:
1 | uint64 |
路径检查函数
在 kernel/syscall.c 中添加了:
1 | *// Check if a path is allowed for sandboxed open/e**xec calls* |
系统调用分发逻辑更新
修改了 syscall() 函数,为 open 和 exec 系统调用添加了特殊的路径检查:
1 | *// Check if this system call is blocked by sandbox* |
继承机制
在 allocproc() 中初始化 p->allowed_path[0] = ‘\0’
在 kfork() 中添加 safestrcpy(np->allowed_path, p->allowed_path, MAXPATH); 确保子进程继承父进程的允许路径
4.attack
通过 sbrk
分配一大段虚拟地址空间,然后在这块内存里搜索 secret
程序留下的标记 "This may help."
,一旦找到就把标记后面 16 字节处的字符串当作“秘密”打印出来。它依赖于内核回收/重用物理页时不清零旧数据的行为,从而窃取别的进程曾经写入的敏感内容
1 |
|
Lab3:page tables
1.解释用户进程的页表
每一个页表项包含:va是用户态的虚拟地址,pte是页表项的原始64位值,包含物理页号+权限位,pa是计算得到的物理页基址,perm是权限位,
0x001 = V
0x002 = R
0x004 = W
0x008 = X
0x010 = U
0x020 = G
0x040 = A
0x080 = D
所以 0x5B = V|R|W|U|A|D。
有些pte=0表示还没分配实际内存页
前 10 页 (0x0 到 0x9000)
0x0:通常是代码段或起始的用户内存。
0x1000:用户栈或数据段的一部分。
有些是空洞 (pte=0),表示还没分配实际物理页。
后 10 页(接近 MAXVA 的高地址)
0xFFFFE000 和 0xFFFFF000 有映射,说明 xv6 把一些用户库函数(比如 ugetpid 的实现)放在高地址。
0xFFFFF000 指向物理 0x80006000,这是内核预留的一段共享页,用来提供用户可直接访问的只读数据(例如内核维护的 PID)。
在 USYSCALL 处建立一个只读共享页,用户进程可以在不陷入内核的情况下直接读取某些信息,从而 避免一次 trap/return,大大减少开销。
进程信息
pid(已经实现)
ppid(父进程 ID)
uid / gid(如果 xv6 扩展用户概念)
exit status(只读缓存,可能需要机制保证同步)
时间相关
当前 tick 数(内核全局时钟中断累计值)
uptime(系统启动到现在的时间)
(可选)高精度时钟值(如果要优化 gettimeofday() 这种调用)
内核信息
nproc(当前进程数,用于教学/实验)
loadavg(平均负载,简单版本)
调度相关
当前 cpu id(对应 cpuid())
当前进程的调度优先级/权重
2.加快系统调用
用户空间的 ugetpid 函数已经实现,它直接从 USYSCALL 地址读取 PID。
修改 proc 结构体,添加 usyscall 字段
修改这些函数来支持 USYSCALL 映射。首先修改 proc_pagetable 函数和proc_freepagetable函数
修改 allocproc 函数来分配和初始化 usyscall 页面
修改 freeproc 函数来释放 usyscall 页面
需要初始化 usyscall 结构体,存储当前进程的 PID。需要在 allocproc 函数中,在分配 usyscall 页面之后添加初始化代码
找到了 kfork 函数,这是实际的 fork 实现,在复制 trapframe 之后添加 usyscall 的复制
3.打印页表
kernel/vm.c
1 |
|
上面这个没过测试
1 |
|
4.超级页
好难。。。(失败
关于vm.c
1 | 内核页表管理函数 |
Lab4:traps
由于2025年的实验代码没有写好,从lab4改用2024年的版本
1.RISC-V assembly
哪些寄存器包含传递给函数的参数?例如,main 调用 printf 时,哪个寄存器存储 13?
a1存储12,a2存储13,a1
、a2
包含函数参数。
在 main 的汇编代码中, f 函数的调用在哪里? g 函数的调用在哪里?
f 和 g 的调用参数由于都是编译期常数,都被编译器直接计算了,a1 里存的 12 就是 f(8)+1 的计算结果。
printf 函数位于哪个地址?
6bc
在 jalr 到 printf 的 main 中,寄存器 ra 中包含什么值?
ra=0x34
unsigned int i = 0x00646c72;
printf(“H%x Wo%s”, 57616, (char *) &i);
输出是什么
He110 World
在以下代码中, ‘y=’ 之后将要打印什么?(注意:答案不是特定值。)为什么会这样?
printf(“x=%d y=%d”, 3);
printf 会继续在调用栈上“读”一个不存在的参数位置。y 会打印 a2 的值,因为 a2 是第三个函数传入参数。
2.backtrace
在 kernel/printf.c
中实现一个 backtrace()
函数,在 sys_sleep
中插入对该函数的调用
riscv.h中添加
1 | static inline uint64 |
defs.h中添加backtrace()的声明
printf.h中写backtrace函数
1 | void backtrace(void) |
3.alarmtest
添加sysalarm和sysreturn 系统调用
trap.c
1 | // 如果是定时器中断,可能需要抢占 CPU 或处理 alarm |