mit6.s081 lab2实验记录
1、System calls
题目描述:
添加一个系统调用trace,为每个进程设定一个位mask,用mask中设定的为来指定要为那些系统调用输出调试信息。
教程参考:
我觉这个已经写的很好了就直接上链接吧:[mit6.s081] 笔记 Lab2: System calls | 系统调用
实验过程中的几个问题:
Q1:为什么在proc的进程上下文寄存器a0中可以读取到mask的值?
// kernel/sysproc.c
// 这里着重理解如何添加系统调用,
//对于这个调用的具体代码细节在后面的部分分析
uint64 sys_trace(void)
{
int mask;
if(argint(0, &mask) < 0)
return -1;
myproc()->syscall_trace = mask;
return 0;
}
A:看了关于结构体trapframe的描述,这应该可以理解为由用户态进入内核态后,内核空间中进程控制块(PCB)在xv6系统中就是结构体proc里面保存进程上下文的结构体。他保存了中断或异常的需要恢复的各种寄存器的值,比如进程的内核栈指针、各种寄存器的值。其中a0到a7寄存器用于保存进程的 执行参数,我的理解是mask是进程的第一个参数,所以可能在进入内核态后就将其保存在寄存器a0中。所以sys_trace函数会从a0获取mask并将其保存到proc结构体新添加的变量syscall_trace中。
Q2:为什么trace函数只有声明没有具体的函数体实现,也可以实现功能?
trace.c:
if (trace(atoi(argv[1])) < 0) {
fprintf(2, "%s: trace failed\n", argv[0]);
exit(1);
}
user.h:
int sleep(int);
int uptime(void);
int trace(int); //只添加了一个声明
A:因为我们并不需要trace函数的具体实现,他只是作为一个跳板,作为进入内核态执行系统调用的入口,并且获取他的参数就可以了。在usys.pl文件中,通过entry(“trace”)将trace定义为入口,接着后续的工作就由系统调用来实现了。
Q3:为什么系统调用的全流程是下面这样?
user/user.h: 用户态程序调用跳板函数 trace()
user/usys.S: 跳板函数 trace() 使用 CPU 提供的 ecall 指令,调用到内核态
kernel/syscall.c 到达内核态统一系统调用处理函数 syscall(),所有系统调用都会跳到这里来处理。
kernel/syscall.c syscall() 根据跳板传进来的系统调用编号,查询 syscalls[] 表,找到对应的内核函数并调用。
kernel/sysproc.c 到达 sys_trace() 函数,执行具体内核操作
A:阅读kernel/trap.c中的usertrap函数,发现该函数是处理中断、异常或者系统调用进入内核态的处理函数。
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void usertrap(void)
{
int which_dev = 0;
if ((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if (r_scause() == 8)
{
// system call
if (p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall(); // 从这里可以看出syscall是系统调用的第一个函数
}
else if ((which_dev = devintr()) != 0)
{
// ok
}
else
{
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if (p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if (which_dev == 2)
yield();
usertrapret();
}
- 首先,检查当前处理器是否处于用户态下,如果不是,则表示出现错误,调用 panic 函数抛出 panic 异常。
- 此时,当前处理器已经处于内核态,所以将中断和异常交给 kerneltrap() 处理,实现方式是调用 w_stvec(),修改 stvec寄存器的内容为 kernelvec。
- 获取当前进程指针 myproc(),并保存用户程序计数器r_sepc() 到当前进程的 trapframe 结构体中的 epc 字段。
- 判断中断原因,如果是系统调用(r_scause() == 8),则先检查当前进程是否已经被 kill,若是则调用 exit(-1) 结束当前进程。接着,将 epc 指向系统调用指令的下一条指令,以便返回用户进程运行的下一个指令。最后,打开中断并调用内部函数 syscall() 执行系统调用。
- 如果中断或异常不是由系统调用引起的,检查是否由设备(如定时器硬件)引起的中断,如果是,则继续处理;如果不是,则表示出现了异常情况,调用 printf 输出调试信息,并将当前进程的 killed 字段设置为 1,表示需要结束当前进程。
- 最后,检查当前进程是否已经被 kill,如果是则调用 exit(-1) 结束当前进程。若设备引起的中断是定时器,就调用 yield() 函数将当前进程放弃 CPU。然后调用 usertrapret() 内部函数返回到用户态。
综上分析可知,syscall是执行系统调用的第一个函数,syscall通过系统调用的函数指针数组实现了动态调用系统调用函数。
总结:
这一个实验主要考察的是对进程内存地址空间分布、内核态和用户态之间的区别和联系、以及系统调用的实现原理。进程的虚拟内存地址空间分为用户态和内核态,用户态分为bss数据区、data区、代码区、堆、栈,保存着程序的二进制代码、数据、堆栈等。内核态包含了内核代码、数据和内核堆栈,用于执行内核的功能和服务。内核空间和用户空间是相互隔离的,用户进程无法直接访问内核空间的代码和数据,可以通过系统调用向内核请求服务。同理内核代码也无法直接访问用户空间的数据,可以通过进程控制块(内核中保存进程的pid、进程上下文、堆栈指针等数据)获取数据。
为了访问用户空间中的数据,内核需要使用特定的函数(如 copy_from_user() 和 copy_to_user())将用户空间中的数据复制到内核空间中,然后再进行访问和操作。这种方式可以保证内核代码的安全性和稳定性,避免了用户进程对内核数据的非法访问和修改。同时,也保证了用户进程的隔离性,避免了不同进程之间的数据冲突和干扰。
2、Sysinfo (moderate)
实验描述:
实现一个sysinfo的系统调用,主要作用是将当前可用的物理内存的字节数和当前正在执行的进程数量保存到一个sysinfo结构体中。
实验分析:
第二个实验与第一个有很多重复的操作,比如新增一个sysinfo的系统调用的流程,还需要在进程控制块中新增一个记录用户空间中sysinfo结构体变量的逻辑地址,以供内核空间使用。
1、学习copyout函数的使用,如何将内核数据复制到用户空间的逻辑地址上,kernel/file.c
// Get metadata about file f.
// addr is a user virtual address, pointing to a struct stat.
int
filestat(struct file *f, uint64 addr)
{
struct proc *p = myproc();
struct stat st;
if(f->type == FD_INODE || f->type == FD_DEVICE){
ilock(f->ip);
stati(f->ip, &st);
iunlock(f->ip);
if(copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
return -1;
return 0;
}
return -1;
}
该函数的实现了将内核中的struct stat结构体变量st从内核中复制到用户虚拟地址为addr的地方,copyout的参数依次是进程控制快的页表地址、逻辑地址、内核变量的地址、内核变量的大小。这个过程个人理解的首先应该是一个地址变换的过程,通过页表找到对应的物理块号,再生成对应的物理地址,接着将数据写入该物理内存中,这样就是实现了将内核数据复制到用户空间中。
2、学习kernel/kalloc.c中物理内存块的存储方式,并统计空闲内存块的大小
extern char end[]; // first address after kernel.
// defined by kernel.ld.
struct run // 保存空闲物理页的信息 包含一个指向下一个空闲物理页的指针(因为物理块是离散的)
{
struct run *next;
};
struct
{
struct spinlock lock; // 他是一个共享资源所以需要互斥访问
struct run *freelist;
} kmem; // 自由物理页表
在xv6系统的内核中,空闲物理块的组织方式是按照链表实现的。上述结构体run就是一个的内部链表节点,kmem表示系统内存的空闲内存块链表头结点,拥有链表锁和指针。需要注意的是,这里的内存链表是内存块的链表,保存着内存块的首地址,所以在统计字节数时还需要乘以页块的大小PGSIZE。所以要获取空闲内存的字节数,只需要遍历空闲内存块链表即可,统计长度再乘以PGSIZE。
3、学习kernel/proc.c中进程管理数组的组织形式,并统计状态不为unused的进程数量
参考下面的函数:
// initialize the proc table at boot time.
void procinit(void)
{
struct proc *p;
initlock(&pid_lock, "nextpid"); // 对全局进程id锁pid_lock初始化
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");
// 按照进程在进程管理数组中的位置,指定内核栈的虚拟地址
// 每个进程的内核栈开始地址是 KSTACK(i),其中 i 是进程在 proc 数组中的下标。
uint64 va = KSTACK((int)(p - proc));
kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W); // 将这个虚拟地址映射到内存块上
p->kstack = va; // 将进程内核栈的首地址保存到对应变量中
}
kvminithart();
}
这是一个初始化进程控制块的函数,可以看出内核中有一个进程管理数组,最大进程数量为NPROC。那么只要遍历这个数组并筛选出状态不为unused就可以了。
代码实现
//kernel/proc.c
uint64 getNporc(void) // 统计所有使用的进程数量
{
struct proc *p;
uint64 count = 0;
for (p = proc; p < &proc[NPROC]; p++)
{
if (p->state != UNUSED)
count++;
}
return count;
}
//kernel/kalloc.c
uint64 getFremem(void) // 获取空闲页块的个数
{
// 直接遍历链表并统计长度
struct run *p;
uint64 len = 0;
acquire(&kmem.lock); // 获取锁
p = kmem.freelist;
while (p)
{
p = p->next;
len++;
}
release(&kmem.lock);
return len;
}
//kernel/sysproc.c
// 实现一个输出当前内存空闲字节 和当前运行的进程数的系统调用 并返回到用户空间
uint64 sys_sysinfo()
{
struct sysinfo sinfo;
sinfo.freemem = getFremem() * PGSIZE;
sinfo.nproc = getNporc();
struct proc *p = myproc();
uint64 addr;
// 从pcb的进程上下文中获取sysinfo的地址
if (argaddr(0, &addr) != 0)
return -1;
// 将数据复制到用户空间指定的地址上
if (copyout(p->pagetable, addr, (char *)&sinfo, sizeof(sinfo)) < 0)
return -1;
return 0;
}
一些细节
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](); // 执行对应的系统调用并返回给a0
// 输出追踪的信息依次为:进程id 执行的系统调用名称 系统调用的返回值
// printf("%d: syscall %s -> %d\n", p->pid, syscall_name[num], p->trapframe->a0);
if ((p->syscall_trace >> num) & 1)
{
// 如果当前进程的mask左移该系统调用的编号仍为1的话 就trace该系统调用
// 如果当前进程设置了对该编号系统调用的 trace,则打出 pid、系统调用名称和返回值。
printf("%d: syscall %s -> %d\n", p->pid, syscall_name[num], p->trapframe->a0);
}
}
else
{
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
这里内核是从进程上下文的a7寄存器获取系统调用的编号num,那么为什么是从寄存器a7获取?
阅读user/usys.pl
sub entry {
my $name = shift;
print ".global $name\n";
print "${name}:\n";
print " li a7, SYS_${name}\n"; # 在这里将系统调用的编号存入a7寄存器
print " ecall\n"; # 进入内核态
print " ret\n";
}
# 进入内核态的入口函数
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
发现,print " li a7, SYS_${name}\n";
在这里将系统调用的宏定义放在a7寄存器。