xv6操作系统系统调用过程解析

xv6操作系统系统调用过程解析

我们都知道,unix操作系统有用户模式和内核模式,用户空间中运行的程序在用户模式下,内核空间运行的程序在内核模式下,操作系统位于内核空间。其中操作系统负责连接用户程序和下层硬件,所以操作系统会接收来自用户程序的请求或者来自硬件的响应,具有对硬件的完全访问权限。用户模式则被限制,不能之间访问磁盘,内存等硬件,因为这样可能会导致系统崩溃。而此时,操作系统就作为了硬件与用户程序的中间层,进行资源分配,统一接口的作用。

但是用户进程运行在用户态,操作系统运行在内核态,二者的界限是一个硬性的界限,由于用户态运行的程序存在权限问题,那么用户态的程序如何借助内核态的程序与硬件沟通或者执行一些内核态下才能执行的指令呢?答案就是用户态进程进行系统调用。

系统调用是操作系统封装起来后提供给用户层的接口,例如在xv6操作系统中,就有如下系统调用:

c 复制代码
//user.h
// system calls
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);

有了这些接口,用户态的程序就可以找到系统调用的函数。在用户态程序找到上述函数声明之后,就会找到usys.pl中生成的包装函数,这些包装函数提供一个和普通C函数一样的接口,使得用户程序可以直接调用。

perl 复制代码
sub entry {
    my $name = shift;
    print ".global $name\n";
    print "${name}:\n";
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}
	
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");

我们可以看到这些跳转到内核中的跳转函数都调用了一个ECALL指令,ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字到a7寄存器中。这里的数字参数代表了应用程序想要调用的System Call。

ECALL指令就做了以下几件事,首先将CPU切换到内核态,然后记录陷入内核态的原因和程序计数器等到特定寄存器,最后加载固定的从用户态切换到内核态的处理函数的指令地址到PC寄存器,经过调试代码发现,执行ECALL之后就执行到了内核的trampoline.S这个汇编文件,看代码注释可知,这段汇编代码的作用就是保存了用户程序的寄存器到该程序的trapframe中,便于执行完系统调用之后再恢复寄存器。之后这个汇编代码会将PC寄存器的值设置为trap.c文件中的usertrap函数。

在这个函数中,由于我们执行ecall时已经设置了陷入内核的原因,所以我们会走到以下分支:

c 复制代码
if(r_scause() == 8){
    // system call

    if(p->killed)
        exit(-1);

    // 这里时为了返回之后可以执行ecall的下一跳指令,而不是重新执行ecall,陷入死循环
    p->trapframe->epc += 4;

    // 只有在关键寄存器操作完成后,才调用 intr_on(),避免中断期间寄存器状态被破坏。
    intr_on();

    syscall();
}

// Supervisor Trap Cause
static inline uint64
    r_scause()
{
    uint64 x;
    asm volatile("csrr %0, scause" : "=r" (x) );
    return x;
}

在这段逻辑中会走到syscall.c文件中的syscall()函数中,这是内核态统一系统调用处理函数 syscall(),所有系统调用都会跳到这里来处理。syscall() 根据跳板传进来的系统调用编号,这里就是我们之前在跳板函数中设置的a7寄存器的值,查询 syscalls[] 表,找到对应的内核函数并调用,注意我们要将返回值记录到a0寄存器。

c 复制代码
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]();
    } else {
        printf("%d %s: unknown sys call %d\n",
               p->pid, p->name, num);
        p->trapframe->a0 = -1;
    }
}

最后调用syscalls[num]()时会走到sysproc.c文件中找到对应的系统调用实现。这么繁琐的调用流程的主要目的是实现用户态和内核态的良好隔离。

执行完系统调用之后就会返回到usertrap函数中,准备从内核空间返回到用户空间。

c 复制代码
usertrapret();

这个函数就是将trapframe 里的内容(即用户态的寄存器、程序计数器等)恢复到 CPU。在返回用户态前,切换到用户进程自己的页表,保证用户程序只能访问自己的内存空间。跳转到 trampoline 区域,执行 sret,CPU 切换回用户态,进程从 ecall 的下一条指令继续执行。

这就是整个系统调用的过程了。

相关推荐
冬奇Lab8 小时前
一天一个开源项目(第81篇):YC 总裁亲自写代码,把自己的大脑开源了
人工智能·开源·资讯
头发还在的女程序员10 小时前
家政SaaS平台开源:从供应商入驻到分账结算,源码如何设计?
小程序·开源
X.AI66614 小时前
小米 MiMo‑V2.5‑Pro 上手体验:一款能硬刚 GPT‑5.4 的国产大模型有多强?
人工智能·gpt·开源
一只AI打工虾的自我修养14 小时前
开源大模型本地部署:Ollama vs LocalClaw 选型指南
人工智能·开源
MXN_小南学前端14 小时前
Vue3 + Spring Boot 工单系统实战:用户反馈和客服处理的完整闭环(提供gitHub仓库地址)
前端·javascript·spring boot·后端·开源·github
智碳未来科技有限公司14 小时前
开源能碳管理系统:助力 2026 绿色工厂申报
开源·能源·双碳目标·制造业转型·能碳管理·绿色工厂
墨染天姬15 小时前
【AI】DeepSeek开源cuda算子库TileKernels
人工智能·开源
代码AI弗森16 小时前
OpenMUSE 全面详解:非扩散Transformer文生图开源基座(对标GPT Image 2)
gpt·开源·transformer
MU在掘金9169517 小时前
一个CLI工具的架构是怎么搭起来的
性能优化·开源
Yunzenn17 小时前
零基础复现Claude Code(四):双手篇——赋予读写文件的能力
开源·github