MIT 6.S081 syscall 实验篇(lab2):Sysinfo (moderate)
Sysinfo (moderate)
实验目标
本实验要新增一个系统调用sysinfo,让用户程序能查询两项系统状态:
freemem:当前空闲物理内存的字节数;nproc:当前处于活动状态的进程数量(即UNUSED之外的进程数)。
教材已经在kernel/sysinfo.h把承载这两项数据的结构体定义好了,我们要做的就是把数据填进去、并把它安全地送回用户进程:
/* * kernel/sysinfo.h */structsysinfo{uint64 freemem;// amount of free memory (bytes)uint64 nproc;// number of process};和 trace 实验的本质区别:trace 只是在内核里
printf打印,数据不需要出内核;而sysinfo要把内核采集到的数据跨越地址空间拷贝回用户进程——这正是本实验最关键、也最容易踩坑的地方。
前置知识
动手前,先立住三个概念。
1. 一个系统调用从用户态到内核态的完整链路
用户程序调用sysinfo()后,并不会直接进内核,而是走这样一条路径:
用户程序 sysinfo() ↓ usys.S 里的跳板(ecall 指令) ↓ trampoline.S uservec(保存现场到 trapframe) ↓ usertrap()(kernel/trap.c) ↓ syscall()(kernel/syscall.c,统一分发入口) ↓ sys_sysinfo()(真正的内核处理函数,kernel/sysproc.c) ↓ 返回 syscall() → usertrapret() → userret → 回到用户态所以所有系统调用的"总入口"都是syscall(),而"具体干活的"是sys_xxx。新增系统调用,本质就是把一条新链路接进这套既有框架。
2. 跨地址空间的数据传递(本实验核心)
sysinfo的难点不在"怎么算",而在"怎么把结果给用户":
- 用户程序传进来的
struct sysinfo *是用户虚拟地址,存在于该进程自己的用户页表里; - 内核处理函数里声明的
struct sysinfo info在内核地址空间; - 内核不能直接往用户虚拟地址写,因为两边地址空间不同。
所以标准做法是:
structsysinfoinfo;// 1. 内核空间里准备好结构freebytes(&info.freemem);// 2. 内核里填数据procnum(&info.nproc);uint64 dstaddr;argaddr(0,&dstaddr);// 3. 取出用户传来的指针(用户虚拟地址)copyout(myproc()->pagetable,dstaddr,(char*)&info,sizeofinfo);// 4. 内核→用户 拷贝copyout内部会逐页把用户虚拟地址翻译成物理地址再memmove,它替你处理了跨页、越界检查,是内核安全地写用户内存的唯一正确姿势。
3. 两个统计数据的来源
| 数据 | 存在哪 | 怎么统计 |
|---|---|---|
freemem | kernel/kalloc.c的空闲链表kmem.freelist | 遍历空闲页链表,每个节点就是一页(PGSIZE),累加倍增 |
nproc | kernel/proc.c的进程表proc[NPROC] | 遍历进程表,凡是state != UNUSED的都算"活动" |
实现思路
顺着"系统调用执行顺序",需要改动的地方是一条连贯的链:
- 用户态要能调到
sysinfo→user/user.h声明(含struct sysinfo前向声明)+user/usys.pl生成跳板 +Makefile把_sysinfotest编进镜像。 - 内核要认得这个新系统调用→
kernel/syscall.h分配号SYS_sysinfo = 23。 sysinfo的内核实现→kernel/sysproc.c里写sys_sysinfo():填info、取用户指针、copyout回用户。- 两个统计函数→
freebytes()放kalloc.c(离空闲链表最近),procnum()放proc.c(离进程表最近)。 - 让
sys_sysinfo找到这两个函数→ 在kernel/defs.h补上extern风格的声明(注意它们不是sys_开头,所以不参与syscalls[]分发,只需普通声明)。 - 把
sys_sysinfo接进分发数组→kernel/syscall.c里extern+ 挂进syscalls[]+sysnames[]加名字。
一句话:加用户接口 → 分配号 → 实现处理函数 → 实现两个统计 → 补声明 → 注册分发。
代码实现
kernel/sysinfo.h—— 结构体(教材已给)
本实验的数据载体,教材直接提供,无需修改,仅作引用:
/* * kernel/sysinfo.h */structsysinfo{uint64 freemem;// amount of free memory (bytes)uint64 nproc;// number of process};用户态三处修改
( a )user/user.h—— 声明接口 + 前向声明结构体
int sysinfo(struct sysinfo*)用到了struct sysinfo类型,所以先在文件顶部加一句前向声明struct sysinfo;,否则函数声明里的参数类型编译器不认识:
/* * user/user.h */structstat;structrtcdate;structsysinfo;// 添加前向声明// system callsintfork(void);...intuptime(void);inttrace(int);intsysinfo(structsysinfo*);// 用户态函数声明注意:用户程序真正分配
struct sysinfo info;变量时需要完整定义,这由user/sysinfotest.c里的#include "kernel/sysinfo.h"提供;user.h里只做函数声明,所以前向声明struct sysinfo;就够用了。
( b )user/usys.pl—— 生成用户态→内核态跳板
usys.pl是 Perl 脚本,编译期为每一行entry("xxx")生成一小段汇编(usys.S),用ecall陷入内核、把系统调用号塞进a7。补上sysinfo即可:
/* * user/usys.pl */...entry("uptime");entry("trace");entry("sysinfo");// sysinfo 系统调用的内核态跳板( c )Makefile—— 把测试程序编进内核镜像
UPROGS列出所有随内核一起编译的用户程序。缺了_sysinfotest,sysinfotest命令就不存在。
/* * Makefile */...ifeq($(LAB),syscall)UPROGS+=\ $U/_trace\ $U/_sysinfotest endif...kernel/syscall.h—— 分配系统调用号
为每个系统调用分配一个唯一整数编号。我们把它定为23(紧接已有的SYS_trace 22):
/* * kernel/syscall.h */// System call numbers#defineSYS_fork1#defineSYS_exit2#defineSYS_wait3#defineSYS_pipe4#defineSYS_read5#defineSYS_kill6#defineSYS_exec7#defineSYS_fstat8#defineSYS_chdir9#defineSYS_dup10#defineSYS_getpid11#defineSYS_sbrk12#defineSYS_sleep13#defineSYS_uptime14#defineSYS_open15#defineSYS_write16#defineSYS_mknod17#defineSYS_unlink18#defineSYS_link19#defineSYS_mkdir20#defineSYS_close21#defineSYS_trace22#defineSYS_sysinfo23kernel/syscall.c—— 声明并注册处理函数
本次改动有三处:① 顶部extern声明sys_sysinfo;② 挂进syscalls[]分发数组(下标 = 调用号);③sysnames[]加上名字供打印。
/* * kernel/syscall.c */...externuint64sys_chdir(void);...externuint64sys_trace(void);externuint64sys_sysinfo(void);staticuint64(*syscalls[])(void)={[SYS_fork]sys_fork,...[SYS_trace]sys_trace,[SYS_sysinfo]sys_sysinfo,};staticconstchar*sysnames[]={[SYS_fork]"fork",...[SYS_close]"close",[SYS_trace]"trace",[SYS_sysinfo]"sysinfo",};kernel/sysproc.c—— 实现sys_sysinfo(核心)
本次改动是顶部#include "sysinfo.h"和文件末尾的sys_sysinfo。注意它先在内核空间填好info,再用argaddr(0, &dstaddr)取出用户指针,最后copyout把数据送回用户进程——这就是「前置知识」里说的跨地址空间拷贝:
/* * kernel/sysproc.c */#include"types.h"#include"riscv.h"...#include"sysinfo.h"// 文件开头加上sysinfo结构体的头文件...uint64sys_sysinfo(void){structsysinfoinfo;freebytes(&info.freemem);// 获取空闲内存的字节数量procnum(&info.nproc);// 获取处于活动状态的进程数量// 获取用户虚拟地址uint64 dstaddr;argaddr(0,&dstaddr);// 从内核空间拷贝数据到用户空间if(copyout(myproc()->pagetable,dstaddr,(char*)&info,sizeofinfo)<0)return-1;return0;}
user/sysinfotest.c调用sysinfo(&info)时,传入的&info就是这个dstaddr。内核把统计好的info通过copyout写进用户那块内存,用户程序才能真正读到freemem/nproc。这一来一回,正是sysinfo与仅在内核打印的trace最本质的区别。
kernel/kalloc.c—— 实现freebytes(统计空闲内存)
xv6 用空闲链表管理空闲物理页:每个空闲页自身的前几个字节被复用成struct run节点,r->next串起下一页,kmem.freelist是链表头。遍历链表、每跳一步加一个PGSIZE就是空闲字节数。记得加kmem.lock:因为kalloc/kfree会并发改这条链表,不加锁会读到正在变化的指针:
/* * kernel/kalloc.c */...voidfreebytes(uint64*dst){*dst=0;structrun*p=kmem.freelist;acquire(&kmem.lock);// 加锁保证线程安全while(p){*dst+=PGSIZE;// 统计空闲的字节数p=p->next;}release(&kmem.lock);}kernel/proc.c—— 实现procnum(统计活动进程)
proc[NPROC]是全局进程表,每个进程的state字段标明了它是否在使用。遍历这张表,凡是state != UNUSED的都算"活动进程"。下面贴的是proc.c完整文件,本次只新增了文件末尾的procnum,其余为既有代码:
/* * kernel/proc.c */...voidprocnum(uint64*dst){*dst=0;structproc*p;for(p=proc;p<&proc[NPROC];p++){if(p->state!=UNUSED){(*dst)++;}}}...该实现遍历进程表时没有加
p->lock。在评测环境(sysinfotest)下单次调用、没有并发创建/销毁进程的竞争,所以能稳定通过;若在真实多核高并发场景下,理论上有极小概率读到不一致的中间状态——生产代码通常要持锁或配合原子操作。面试时展示"我清楚这里的权衡"。
kernel/defs.h—— 声明两个统计函数
freebytes/procnum不是sys_开头的系统调用,不参与syscalls[]分发,所以只需在defs.h里做普通声明,sysproc.c就能找到它们。
/* * kernel/defs.h */...// kalloc.cvoid*kalloc(void);voidkfree(void*);voidkinit(void);voidfreebytes(uint64*dst);...// proc.cintcpuid(void);voidexit(int);intfork(void);intgrowproc(int);pagetable_tproc_pagetable(structproc*);voidproc_freepagetable(pagetable_t,uint64);intkill(int);structcpu*mycpu(void);structcpu*getmycpu(void);structproc*myproc();voidprocinit(void);voidscheduler(void)__attribute__((noreturn));voidsched(void);voidsetproc(structproc*);voidsleep(void*,structspinlock*);voiduserinit(void);intwait(uint64);voidwakeup(void*);voidyield(void);inteither_copyout(intuser_dst,uint64 dst,void*src,uint64 len);inteither_copyin(void*dst,intuser_src,uint64 src,uint64 len);voidprocdump(void);voidprocnum(uint64*dst);...验证
回到 xv6 目录,编译并运行syscall分支的评测,指定只测sysinfo子项:
$ ./grade-lab-syscall sysinfo==Test sysinfotest==sysinfotest: OK(6.1s)只要返回sysinfotest: OK,即代表freemem、nproc两项统计与跨地址空间拷贝都正确。
复盘
本实验解决了什么
在系统调用统一分发框架内新增了sysinfo,它能安全地把内核视角的两项全局状态(空闲内存、活动进程数)通过copyout跨越地址空间传递给用户进程。设计上的关键点是:sys_sysinfo不参与"打印",而是"采集 + 回拷"——这正是它和同分支trace实验最本质的区别。
与 trace 实验对比
| 维度 | trace | sysinfo |
|---|---|---|
| 数据去向 | 内核printf直接打印 | copyout回拷到用户内存 |
| 是否需要跨地址空间 | 否 | 是(核心难点) |
| 新增状态字段 | 进程级trace_mask | 无(读全局链表/表) |
| 处理函数落点 | sysproc.c | sysproc.c+kalloc.c+proc.c |
收获
- 内核如何安全暴露数据给用户:必考
copyout/copyin机制,能讲清"用户虚拟地址 ≠ 内核地址、必须逐页翻译+越界检查"会非常加分。 - 空闲内存统计:xv6 空闲链表是把空闲页自身当节点(
struct run)的巧妙做法,能口述"遍历kmem.freelist、每节点 +PGSIZE"即可。 - 进程表遍历:
proc[NPROC]+state != UNUSED计数;顺带讲清进程状态机(UNUSED→RUNNABLE→RUNNING→SLEEPING→ZOMBIE)更佳。 - 新增系统调用 6 步模板:本实验 + trace 实验合起来就是"加系统调用"的完整范本(用户接口 → 分配号 → 处理函数 → 关联辅助函数 → 头文件声明 → 注册分发),后续 labs 反复用到。