6.S081 学习笔记 - Lab-System-calls

操作系统架构

操作系统需要实现三个基本功能:多路复用、隔离和交互。

操作系统将硬件资源抽象为服务实现这三个功能。

操作系统在进程间透明地切换资源,程序不必意识到自己在和其他程序分时共享资源。即使某个程序处于无限循环,也不会导致其他程序获取不到资源。

程序通过系统调用访问敏感资源,操作系统就能对访问进行合理的控制,避免程序之间的干扰。

系统资源的抽象使得进程间的交互变得简单,例如文件描述符抽象了许多细节,进程不用关心它到底是从文件还是管道读取数据。

操作系统必须保证普通程序不能修改甚至访问属于操作系统的数据结构和指令,也不能访问其他程序的内存。这样一个程序的错误就不会影响到操作系统或其他程序。

CPU 为这种强隔离提供了硬件支持。如 RISC-V 有机器模式 (Machine Mode)、特权模式 (Supervisor Mode) 和用户模式 (User Mode)。机器模式拥有最高权限,CPU 启动时运行在机器模式,对计算机进行配置,然后切换到特权模式。在特权模式下,允许执行特权指令。以特权模式运行的程序称为内核 (kernal),内核运行在内核空间。应用程序运行在用户空间中,只能执行用户模式的指令。

普通程序调用内核函数时必须转交给内核,CPU 会转换到特权模式在内核指定的入口点处执行代码,对系统调用的参数进行校验,判断程序能否执行这个系统调用。如果入口点可以由程序控制,那么恶意程序就可以跳过校验。

宏内核 (Monolithic Kernel):整个操作系统在内核中,所有的系统调用都以特权模式运行。优点是设计简单,便于系统不同部分配合,缺点是接口复杂,开发时容易出错。一旦内核出现问题,整个系统就会崩溃,需要重启。

微内核 (Microkernel):最小化特权模式下运行的代码,将大部分操作系统功能放在用户空间。优点是内核相对简单,稳定性高,缺点是性能较差。

内核实现进程的机制包括用户 / 特权模式标志,地址空间和线程时间片。进程的抽象会给程序一种它独占了整个计算机的错觉。

Xv6 使用页表给每个进程分配独立的地址空间,页表将虚拟地址映射到物理地址。虚拟地址从 0 开始依次是用户空间指令、全局变量、栈和堆。

每个进程有一个执行线程,线程可以挂起并在稍后恢复执行。操作系统就是通过挂起一个线程,再恢复另一个线程来实现进程间的透明切换的。大部分线程的状态存储在线程的栈中。每个进程有两个栈,一个用户栈,一个内核栈。

进程执行用户指令时,只会使用用户栈,内核栈是空的。当进程进入内核,内核代码会在内核栈上执行,用户栈保持不变。独立内核栈使得用户栈坏掉了内核也能正常运行。

进程执行 RISC-V 的 ecall 进行系统调用,ecall 指令会将程序从用户模式切换到特权模式,然后跳转到内核的入口点。代码在入口点处切换到内核栈,然后执行系统调用的内核指令。系统调用完成后,内核会执行 sret 指令降低权限回到用户空间,继续执行系统调用之后的指令。

源码阅读

  • user/user.h 中声明了供用户程序调用的系统调用函数和 C 库函数。
  • user/usys.pl 是一个 Perl 脚本,程序化生成系统调用汇编的存根,存入 usys.S 文件。存根是一个简短的代码段,将系统调用号和参数传递给内核,并发起系统调用。
  • kernel/syscall.h 中定义了系统调用的编号。
  • kernel/syscall.c 对系统调用的编号进行校验,并实现了一些获取系统调用参数的函数。在 syscall 函数中调用了对应系统调用的包装函数。
  • kernel/proc.h 定义了进程相关的数据结构,包括进程的寄存器、状态、内核栈、页表等。
  • kernel/proc.c 中实现了进程相关的系统调用,如 forkwait 等。
  • kernel/sysfile.c 中是文件系统相关的系统调用的包装函数。
  • kernel/sysproc.c 中是进程相关的系统调用的包装函数。
  • kernel/defs.h 中包含多个文件的函数声明。

第一个系统调用

Xv6 启动后的第一个进程执行的是 initcode.S,在此程序中调用了 exec 系统调用重新进入内核。

.S 文件和.asm 文件都是汇编代码文件,区别是.S 文件会经过预处理,可以包含 C 的预处理器指令。比如 initcode.S 中的#include "syscall.h"。而.asm 文件不会经过预处理。

1
2
3
4
5
6
7
8
9
10
11
# 引入系统调用编号
#include "syscall.h"

# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
...

initcode.Sexec 系统调用所需的参数存入 a0a1,将 exec 的编号 SYS_exec 存入 a7。所有系统调用的编号都定义在 syscall.h 中。

syscall.c 中定义了一个函数指针数组 syscalls,索引是系统调用的编号,值是对应函数的指针 (这里其实是系统调用的包装函数)。syscall 函数中取出 a7 寄存器中的系统调用编号,并检验是否合法,然后根据编号在 syscalls 数组中找到对应的函数指针,调用这个函数,将返回值存入 a0 寄存器。一般返回值为 0 表示成功,-1 表示失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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,
...
};

void
syscall(void)
{
int num;
struct proc *p = myproc();

// 获取系统调用编号
num = p->trapframe->a7;
// 检查编号是否合法
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// 调用系统调用函数并将返回值存入a0寄存器
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}

题解

本次实验要求实现一些新的系统调用,涉及到的代码文件较多,关键是通过阅读源码理解各个文件的作用和调用的流程。

System call tracing (moderate)

本题要求实现一个系统调用 tracetrace 接受一个 int 参数 mask,表示要追踪的系统调用的掩码。如 trace(1 << SYS_fork) 会追踪 fork 系统调用,而 trace(1 << SYS_fork | 1 << SYS_exit)trace(6) 会同时追踪 forkexit 系统调用。系统调用编号定义在 kernel/syscall.h 中。

trace 应在参数中指定类型的系统调用返回前打印进程 PID、系统调用名称和返回值。

trace 应能追踪调用它的进程和其子进程中的系统调用,但不会影响其他进程。

思路

  • kernel/syscall.c 中的 syscall 函数中进程进行了系统调用,这里可以获取到进程 PID、系统调用的编号和返回值,因此在此处打印追踪信息。
  • proc 结构体中新增一个成员变量 trace_mask 用于判断是否追踪,追踪哪些系统调用
  • sys_trace 函数中将 trace 系统调用的参数存入 proc->trace_mask
  • fork 时复制父进程的 trace_mask 到子进程
  • 系统调用的名称可通过创建一个数组来存储,索引是系统调用的编号,值是系统调用的名称

实现代码

1
2
3
4
5
// user/user.h
...
int uptime(void);
int trace(int); // 在用户空间声明trace系统调用
...
1
2
3
4
# user/usys.pl
...
entry("uptime");
entry("trace"); # 添加trace系统调用存根
1
2
3
4
// kernel/syscall.h
...
#define SYS_close 21
#define SYS_trace 22 // 定义trace系统调用编号
1
2
3
4
5
6
7
// kernel/proc.h
struct proc {
...
int pid; // 进程ID
int trace_mask; // 新增追踪掩码
...
};
1
2
3
4
5
6
7
8
9
10
11
12
// kernel/sysproc.c
uint64
sys_trace(void)
{
int mask;
// 使用argint获取trace的参数并进行校验
if(argint(0, &mask) < 0)
return -1;
// 使用myproc获取当前进程,将参数存入进程的trace_mask
myproc()->trace_mask = mask;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// kernel/syscall.c
extern uint64 sys_trace(void); // 声明sys_trace函数

static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
...
[SYS_trace] sys_trace, // 将sys_trace函数指针存入syscalls数组
};

// 新增系统调用名称数组
static char* syscall_names[] = {
[SYS_fork] "fork",
...
[SYS_trace] "trace",
};

void
syscall(void)
{
int num;
struct proc *p = myproc();

num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
// 判断当前系统调用是否需要追踪
if((1 << num) & p->trace_mask) {
// 打印追踪信息
printf("%d: syscall %s -> %d\n", p->pid, syscall_names[num], p->trapframe->a0);
}
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/proc.c
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *p = myproc();

// Allocate process.
if((np = allocproc()) == 0){
return -1;
}

// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
np->sz = p->sz;
np->parent = p;
np->trace_mask = p->trace_mask; // 复制父进程的trace_mask到子进程
...
}

Sysinfo (moderate)

本题要求实现一个系统调用 sysinfosysinfo 系统调用接受一个指向 sysinfo 结构体的指针作为参数,并将系统信息填入这个结构体中。sysinfo 结构体包含两个字段:freemem 表示空闲内存的字节数,nproc 表示状态不是 UNUSED 的进程数量。

思路

获取内存信息涉及到了 kalloc.c 文件,先对照参考手册第 3.5 节阅读源码大致弄明白内存是怎么组织的。主要涉及到两个结构体 runkmemrun 结构体是一个链表节点,用于表示内存块,每块 4096 字节。kmem 结构体包含内存的空闲列表 freelist 和一个自旋锁 lock

1
2
3
4
5
6
7
8
struct run {
struct run *next;
};

struct {
struct spinlock lock;
struct run *freelist;
} kmem;

freelist 一开始没有初始化,所以为 0。阅读 kintkfree 函数可以知道 freelist 的作用。

在初始化时,kinit 对所有的内存块使用 kfree 进行初始化。kfree 函数会向指定内存块 r 填入垃圾数据,然后进行下面的操作。

1
2
r->next = kmem.freelist;
kmem.freelist = r;

初始化过程如下,可以看出 freelist 是空闲内存块的头指针。因此我们只需要遍历空闲列表就能知道有多少空闲的内存。

1
2
3
4
5
6
7
// 未初始化时
freelist -> NULL
// 初始化第一个内存块r1
r1(freelist) -> NULL
// 初始化第二个内存块r2
r2(freelist) -> r1 -> NULL
···

第二个功能是获取进程数量,在 proc.c 中有一个进程数组 proc,而根据 proc.hproc 结构体的定义有 state 字段表示进程的状态。所以遍历 proc 数组,统计状态不是 UNUSED 的进程数量即可。

关于如何获取指针参数可以参考 kernel/sysproc.c 中的 sys_wait 函数,将获取到的数据传回用户空间参考 kernel/sysfile.c 中的 sys_fstat 函数和 kernel/file.c" 中的 filestat 函数。

实现代码

首先和 trace 一样,先在用户空间声明 sysinfo 系统调用函数和存根,然后在内核空间定义系统调用编号和包装函数。

1
2
3
4
5
6
7
8
9
10
// user/user.h
struct stat;
struct rtcdate;
struct sysinfo; // 添加前向声明

...
int uptime(void);
int trace(int);
int sysinfo(struct sysinfo *); // 在用户空间声明sysinfo系统调用
...

1
2
3
4
5
# user/usys.pl
...
entry("uptime");
entry("trace");
entry("sysinfo"); # 添加sysinfo系统调用存根
1
2
3
4
5
// kernel/syscall.h
...
#define SYS_close 21
#define SYS_trace 22
#define SYS_sysinfo 23 // 定义sysinfo系统调用编号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// kernel/syscall.c
extern uint64 sys_trace(void);
extern uint64 sys_sysinfo(void); // 声明sys_sysinfo函数

static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
...
[SYS_trace] sys_trace,
[SYS_sysinfo] sys_trace, // 将sys_sysinfo函数指针存入syscalls数组
};

static char* syscall_names[] = {
[SYS_fork] "fork",
...
[SYS_trace] "trace",
[SYS_sysinfo] "sysinfo", // 在名称数组中添加系统调用名称
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// kernel/kalloc.c
// 获取空闲内存大小
uint64 kspace(void) {
struct run* r;
uint64 freemem = 0;

acquire(&kmem.lock);
r = kmem.freelist;
while(r) {
freemem += PGSIZE;
r = r -> next;
}
release(&kmem.lock);

return freemem;
}
1
2
3
4
5
6
7
8
9
10
11
12
// kernel/proc.c
// 获取进程数量
uint64 nproc(void) {
uint64 num = 0;
struct proc *p;
for(p=proc; p < &proc[NPROC]; p++) {
if (p->state != UNUSED) {
num++;
}
}
return num;
}
1
2
3
4
// kernel/defs.h
uint64 kspace(void);
...
uint64 nproc(void);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// kernel/sysproc.c
uint64
sys_sysinfo(void) {
struct proc *p = myproc();
uint64 addr;
struct sysinfo info;

if(argaddr(0, &addr) < 0)
return -1;

info.freemem = kspace();
info.nproc = nproc();

if(copyout(p->pagetable, addr, (char *)&info, sizeof(info)) < 0)
return -1;
return 0;

return 0;
}

总结

实现新系统调用的一般步骤

  1. user/user.h 中声明系统调用的函数原型
  2. user/usys.pl 中添加系统调用的存根
  3. kernel/syscall.h 中定义系统调用的编号
  4. kernel/syscall.c 中声明系统调用的包装函数并将其存入 syscalls 数组,添加系统调用名称到 syscall_names 数组
  5. 实现系统调用函数

在系统调用中和用户空间进行数据交互

  • argint(int n, int *ip):获取一个 int 参数,n 是参数的编号,ip 是指向存储参数的变量的指针
  • argaddr(int n, uint64 *ip):获取一个指针参数,n 是参数的编号,将指针的地址存入 ip 指向的变量
  • argstr(int n, char* buf, int max):获取一个字符串参数,n 是参数的编号,将字符串拷贝到 buf 中,最多拷贝 max 个字符
  • 上面的都是通过 argraw 函数实现的,argraw 函数会从 CPU 寄存器中获取参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    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");
    return -1;
    }
  • copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len):将内核空间的数据拷贝到用户空间,pagetable 是进程的页表,dstva 是虚拟地址,src 是要数据的指针,len 是要拷贝的字节数


6.S081 学习笔记 - Lab-System-calls
http://blog.qzink.me/posts/6.S081学习笔记-Lab-System-calls/
作者
Qzink
发布于
2025年1月5日
许可协议