[mit6.s081] Lab2: systen calls 实验记录
这是实验过程中参考的一些相关资源:
Lab 2:system calls
操作系统需要同时满足三个要求:
- 多路复用:多个进程共享 CPU、内存和设备;
- 隔离:用户程序不能任意访问内核或其他进程;
- 交互:进程可以通过文件描述符、管道和系统调用协作。
xv6 用进程作为主要的隔离单位。一个进程把地址空间和执行线程绑定在一起:
flowchart TB
P[进程]
P --> A[地址空间]
P --> T[执行线程]
A --> A1[页表]
A --> A2[用户内存]
T --> T1[用户栈]
T --> T2[内核栈]
T --> T3[调度上下文]
本实验增加的 trace 是进程的一项配置:该进程以及它通过 fork() 派生的子进程,哪些系统调用需要打印。
系统调用本身也是一种抽象:用户程序不能直接调用内核函数,而是按照约定发起受控请求。
flowchart TB
U[用户程序] --> I[系统调用接口]
I --> E[ecall 跨越特权边界]
E --> D[内核分派器]
D --> K[具体内核实现]
K --> R[返回值与状态]
启动背景
理解 trace 前,需要掌握 xv6 启动链路的几个节点:
flowchart TB
A[entry.S 设置内核栈] --> B[start.c 切换到 Supervisor mode]
B --> C[main.c 初始化内核]
C --> D[userinit 创建第一个进程]
D --> E[scheduler 调度]
E --> F[initcode.S 执行 exec]
F --> G[init.c 启动 shell]
kernel/proc.h:进程状态的容器
proc.h 定义了进程、CPU 和寄存器现场。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct proc {
struct spinlock lock;
enum procstate state;
int pid;
uint64 kstack;
uint64 sz;
pagetable_t pagetable;
struct trapframe *trapframe;
struct context context;
struct file *ofile[NOFILE];
struct inode *cwd;
char name[16];
};
几个字段要区分:
pagetable:用户地址空间;kstack:进入内核后使用的栈;trapframe:用户态寄存器现场;context:内核线程在调度切换时保存的现场;state:UNUSED、RUNNABLE、RUNNING等。
trace_mask 描述进程以后如何被跟踪,因此应该放进 struct proc,而不是放在某个函数的局部变量里。
kernel/defs.h:模块接口
defs.h 只声明内核模块提供的函数,不负责实现。例如 main.c 通过它调用 kinit()、kvminit()、userinit() 和 scheduler()。真正的实现位于 kalloc.c、vm.c、proc.c 等文件中。
这体现了接口思维:调用者只依赖函数的名字、参数和返回值,不需要知道实现细节。
kernel/entry.S 和 kernel/main.c
entry.S 是最早执行的汇编代码。它为每个 CPU 设置内核栈,然后调用 start()。start() 完成 Machine mode 下的早期配置,并通过 mret 进入 Supervisor mode 的 main()。
main() 负责初始化内存、页表、进程表、陷入处理、设备和文件系统,最后调用 userinit() 创建第一个用户进程,再进入 scheduler()。
user/initcode.S 和 user/init.c
initcode.S 是第一个临时用户程序。它把 SYS_exec 放入 a7,把 "/init" 和参数地址放入 a0/a1,执行 ecall。
成功后,当前进程的用户映像被替换成 /init,也就是 user/init.c 编译出来的程序。init.c 打开控制台,设置文件描述符 0、1、2,然后 fork() 并 exec("sh") 启动 shell。
因此:
1
2
initcode.S:把系统推进到 /init
init.c:启动控制台和 shell
实验目标:让内核跟踪系统调用
命令形式:
1
trace mask command [arguments ...]
示例:
1
trace 32 grep hello README
如果 SYS_read == 5,那么:
1
1 << 5 = 32
32 表示跟踪 read。内核在每个系统调用返回前检查 mask,如果对应位被设置,就打印:
1
pid: syscall name -> return_value
例如:
1
3: syscall read -> 1023
这里的设计可以分成两部分:
1
2
sys_trace():设置进程的 trace_mask
syscall():每次调用完成后,根据 trace_mask 决定是否打印
用户程序:user/trace.c
trace 命令本身不跟踪系统调用,它只做两件事:设置 mask,然后用 exec() 变成目标程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int
main(int argc, char *argv[])
{
int i;
char *nargv[MAXARG];
if(argc < 3 || (argv[1][0] < '0' || argv[1][0] > '9')){
fprintf(2, "Usage: %s mask command\n", argv[0]);
exit(1);
}
if(trace(atoi(argv[1])) < 0){
fprintf(2, "%s: trace failed\n", argv[0]);
exit(1);
}
for(i = 2; i < argc && i < MAXARG; i++)
nargv[i-2] = argv[i];
nargv[i-2] = 0;
exec(nargv[0], nargv);
exit(0);
}
对于:
1
trace 32 grep hello README
它会构造:
1
nargv = { "grep", "hello", "README", 0 }
然后执行:
1
2
trace(32);
exec("grep", nargv);
参数数组必须以 0 结尾,否则 exec() 不知道参数在哪里结束。
exec() 替换的是当前进程的用户代码、数据、栈和页表,不是整个 struct proc,所以 pid、父进程、打开的文件和 trace_mask 等进程属性仍然存在:
flowchart TB
A[trace 进程] --> B[trace 设置 mask]
B --> C[exec 替换用户映像]
C --> D[同一进程变成 grep]
D --> E[grep 继续使用 mask]
第一步:接通系统调用接口
新增系统调用时,用户和内核必须共同遵守一套协议:
flowchart TB
A[用户函数声明] --> B[用户态调用桩]
B --> C[系统调用号]
C --> D[内核分派表]
D --> E[内核入口函数]
用户接口
在 user/user.h 中加入:
1
int trace(int);
这只是用户 C 程序看到的接口声明。
系统调用号
在 kernel/syscall.h 中加入:
1
#define SYS_trace 22
这是用户态和内核态共同使用的编号。
调用桩
在 user/usys.pl 中加入:
1
entry("trace");
Makefile 会运行这个 Perl 脚本生成 user/usys.S,其中大致是:
trace:
li a7, SYS_trace
ecall
ret
调用桩(stub)是用户态的一小段汇编桥梁,负责按 ABI 准备寄存器并执行 ecall。使用脚本生成,是因为每个系统调用桩结构几乎相同,脚本可以减少重复代码和编号错误。
把用户程序加入镜像
在 Makefile 的 UPROGS 中加入:
1
$U/_trace\
否则 trace 不会被写入 xv6 的文件系统镜像,启动后也就找不到这个命令。
ecall、寄存器和 trap
调用桩执行:
li a7, SYS_trace
ecall
两条指令的职责不同:
a7传递“要调用哪个系统调用”;ecall触发 CPU 从 User mode 进入 Supervisor mode。
ecall 不会直接调用 sys_trace()。它只产生一个 trap,CPU 跳到内核设置的入口。trampoline.S 保存用户寄存器并切换到进程的内核栈,随后进入 usertrap()。
xv6 使用的寄存器约定是:
1
2
3
a0-a5:系统调用参数
a7:系统调用号
a0:系统调用返回值
进入内核后,寄存器被保存到:
1
2
3
4
p->trapframe->a0
p->trapframe->a1
...
p->trapframe->a7
usertrap() 看到 scause == 8,判断这是用户态执行 ecall,然后调用 syscall()。处理完成后,内核把返回值放回 trapframe->a0,通过 sret 回到用户态。
syscall():系统调用分派器
kernel/syscall.c 中的 syscalls[] 是函数指针数组:
1
2
3
4
5
6
7
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_read] sys_read,
[SYS_exec] sys_exec,
[SYS_trace] sys_trace,
};
它把数字协议映射到内核函数:
1
2
SYS_trace = 22
syscalls[22] = sys_trace
分派逻辑:
1
2
3
4
5
6
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num])
p->trapframe->a0 = syscalls[num]();
else
p->trapframe->a0 = -1;
因此:
1
syscalls[SYS_trace]();
等价于:
1
sys_trace();
这就是系统调用“接口”和“实现”的连接点。
sys_trace():修改当前进程状态
在 kernel/sysproc.c 中:
1
2
3
4
5
6
7
8
9
10
11
uint64
sys_trace(void)
{
int mask;
if(argint(0, &mask) < 0)
return -1;
myproc()->trace_mask = mask;
return 0;
}
调用链是:
flowchart TB
A[trace 32] --> B[调用桩设置 a7]
B --> C[ecall 进入内核]
C --> D[usertrap]
D --> E[syscall 查表]
E --> F[sys_trace 读取参数]
F --> G[保存 trace_mask]
argint(0, &mask) 读取第 0 个系统调用参数。sys_trace() 的职责很小:读取参数、找到当前进程、保存配置、返回结果。它不负责打印后续调用。
trace_mask 的生命周期
新进程:默认清零
进程表是固定数组,进程退出后槽位会变成 UNUSED,以后可能被新进程复用。因此 allocproc() 中要写:
1
p->trace_mask = 0;
这防止新进程继承旧进程槽位中残留的 mask。
fork():复制有语义的状态
fork() 不会原样复制整个 struct proc。PID、内核栈、页表、锁和调度上下文都要重新建立;用户内存、寄存器和部分进程属性则按语义复制。
为了让跟踪对派生出的子进程继续有效:
1
np->trace_mask = p->trace_mask;
父子进程可以执行不同的系统调用,但各自使用相同规则过滤自己的调用。
flowchart TB
A[父进程已有 mask] --> B[fork]
B --> C[子进程复制 mask]
C --> D[父子独立执行]
D --> E[分别按 mask 过滤]
exec():替换程序,不替换进程
exec() 把当前进程的用户程序替换成另一个程序,但 PID、父进程关系、文件描述符和 trace_mask 仍属于当前进程。这正是 trace 命令先设置 mask、再 exec() 目标程序的原因。
在统一分派处打印跟踪结果
系统调用名称表:
1
2
3
4
5
static char *syscall_names[] = {
[SYS_read] "read",
[SYS_exec] "exec",
[SYS_trace] "trace",
};
在 syscall() 中,先调用真正的系统调用,再检查 mask:
1
2
3
4
5
6
7
8
p->trapframe->a0 = syscalls[num]();
if((p->trace_mask & (1U << num)) != 0){
printf("%d: syscall %s -> %d\n",
p->pid,
syscall_names[num],
p->trapframe->a0);
}
顺序必须是:
1
2
3
4
5
6
7
执行 sys_xxx()
↓
得到返回值
↓
检查 trace_mask
↓
打印名称和返回值
这样所有系统调用都经过统一逻辑,而不需要在每个 sys_xxx() 里重复写打印代码。
位运算中的括号
要写:
1
if((p->trace_mask & (1U << num)) != 0)
不要写:
1
if(p->trace_mask & (1U << num) != 0)
后者会被 C 解析成:
1
p->trace_mask & ((1U << num) != 0)
不是在检查第 num 位;在 -Werror 下还会直接导致编译失败。
一次命令的完整流程
以:
1
trace 32 grep hello README
为例:
flowchart TB
A[用户运行 trace] --> B[trace 设置 mask]
B --> C[exec 替换成 grep]
C --> D[grep 发起 read]
D --> E[内核执行 sys_read]
E --> F[检查 mask]
F --> G[打印 read 返回值]
更细的 trace(32) 调用链是:
1
2
3
4
5
6
7
8
9
trace.c: trace(32)
→ user/usys.S: a7 = SYS_trace; ecall
→ trampoline.S: 保存用户寄存器
→ usertrap(): 识别系统调用
→ syscall(): 读取 trapframe->a7
→ syscalls[SYS_trace]
→ sys_trace(): p->trace_mask = 32
→ trapframe->a0 = 0
→ usertrapret() → sret
之后 exec("grep", ...) 替换用户映像。grep 执行 read() 时,又重复同一条系统调用入口路径,只是这次 a7 == SYS_read,分派到 sys_read(),返回后根据 mask 打印。
sysinfo:读取系统整体状态
它和 trace 的区别在于:trace 修改当前进程状态,而 sysinfo 收集内核中的全局信息,再把一个结构体复制回用户空间。
需求如何映射到代码
题目要求 sysinfo(struct sysinfo *) 返回两个字段:
1
2
3
4
struct sysinfo {
uint64 freemem; // 空闲内存字节数
uint64 nproc; // 状态不是 UNUSED 的进程数
};
把题目拆开后,代码位置就很明确:
| 需求 | 数据来源或实现位置 |
|---|---|
| 用户接口 | user/user.h |
| 调用桩 | user/usys.pl |
| 调用号和分派 | kernel/syscall.h、kernel/syscall.c |
| 返回结构体 | kernel/sysproc.c |
| 空闲内存 | kernel/kalloc.c |
| 进程数量 | kernel/proc.c |
完整关系是:
flowchart TB
A[sysinfo 用户接口] --> B[读取用户地址]
B --> C[统计空闲内存]
B --> D[统计进程数量]
C --> E[填充 sysinfo]
D --> E
E --> F[copyout 返回用户空间]
接通系统调用接口
和 trace 一样,先完成四个接口登记:
1
2
3
// user/user.h
struct sysinfo;
int sysinfo(struct sysinfo *);
1
2
# user/usys.pl
entry("sysinfo");
1
2
// kernel/syscall.h
#define SYS_sysinfo 23
1
2
3
4
// kernel/syscall.c
extern uint64 sys_sysinfo(void);
[SYS_sysinfo] sys_sysinfo,
SYS_sysinfo 是数字,sys_sysinfo 是内核函数,"sysinfo" 是打印名称,三者不能混用。
sys_sysinfo() 的职责
实现位于 kernel/sysproc.c:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint64
sys_sysinfo(void)
{
uint64 addr;
struct sysinfo info;
struct proc *p = myproc();
if(argaddr(0, &addr) < 0)
return -1;
info.freemem = freemem();
info.nproc = nproc();
if(copyout(p->pagetable, addr,
(char *)&info, sizeof(info)) < 0)
return -1;
return 0;
}
它的职责只有四步:
1
2
3
4
读取用户指针
→ 在内核中收集数据
→ copyout() 写回用户空间
→ 返回 0 或 -1
argaddr() 只取得用户传入的地址;它不负责保证目标地址可写。真正的用户地址检查由 copyout() 完成,因此非法地址测试会在 copyout() 失败时返回 -1。
这和 sys_fstat() 的模式相同:系统调用入口解析参数,内核函数构造结果,最后通过 copyout() 返回用户空间。
统计空闲内存:freemem()
kernel/kalloc.c 中的物理页分配器用 kmem.freelist 保存空闲页。每个 struct run 节点代表一个大小为 PGSIZE 的空闲页。
1
2
3
4
5
6
7
8
9
10
11
12
13
uint64
freemem(void)
{
uint64 bytes = 0;
struct run *r;
acquire(&kmem.lock);
for(r = kmem.freelist; r; r = r->next)
bytes += PGSIZE;
release(&kmem.lock);
return bytes;
}
这里必须注意:统计函数只能遍历链表,不能像 kalloc() 那样修改 kmem.freelist。因为统计过程和分配/释放可能并发发生,所以要持有 kmem.lock。
统计进程数量:nproc()
kernel/proc.c 中有固定大小的进程表:
1
struct proc proc[NPROC];
题目要求统计所有状态不是 UNUSED 的槽位,因此 USED、SLEEPING、RUNNABLE、RUNNING、ZOMBIE 都要计入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint64
nproc(void)
{
uint64 count = 0;
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state != UNUSED)
count++;
release(&p->lock);
}
return count;
}
每个进程的 state 受对应的 p->lock 保护,因此读取前要加锁,读取后立即释放。这个函数返回的是某个瞬间的快照;其他 CPU 仍可能在统计过程中创建或退出进程。
测试程序如何验证实现
user/sysinfotest.c 主要验证三类行为:
- 非法地址:把无效指针传给
sysinfo(),必须返回-1; - 内存统计:用
sbrk(PGSIZE)分配和释放一页,freemem应分别减少和恢复PGSIZE; - 进程统计:
fork()后进程数增加 1,子进程退出并wait()后恢复。
运行:
1
2
3
make clean
make
make qemu
进入 xv6 后:
1
sysinfotest
成功输出:
1
2
sysinfotest: start
sysinfotest: OK
trace 和 sysinfo 的对比
| 特征 | trace | sysinfo |
|---|---|---|
| 作用 | 修改当前进程的跟踪配置 | 查询系统整体状态 |
| 状态位置 | struct proc.trace_mask | kmem.freelist、proc[] |
| 返回方式 | 只返回整数状态码 | copyout() 返回结构体 |
fork() 关系 | 子进程继承 mask | 统计结果包含子进程 |
| 主要锁 | 进程锁 | kmem.lock 和进程锁 |
这两个实验分别展示了两种常见系统调用:
1
2
控制型系统调用:修改某个内核对象的状态
查询型系统调用:读取内核状态并复制给用户