系统调用流程

Connor

个人感觉在lab4的学习过程中,虽然在写代码的时候跟着注释的提示一点一点的写是可以写出来的,但是想要真正理解 如何具体实现系统调用,fork,这个在整个os代码的流程 感觉还是得画很多时间,个人认为在学习的过程中,总的来说就是在整体中理解各个部分是如何进行协同处理异常,实现中断,正确的函数跳转和处理的返回,在真正理解了整体的流程之后,感觉对于os的整体了解这才更进一步。

本文并没有过多涉及具体的实现逻辑,主要内容是对于 lab4 中的主要流程的梳理。

普通的系统调用的实现

当用户要进行一个系统调用的时候(以进行系统调用 syscall_mem_alloc 为例),在运行的代码中,会出现这个函数 syscall_mem_alloc(envid, va, perm) ,随后,系统开始调用这个函数:会先进入 user/lib/syscall_lib.c 这个文件中,这里面有对该函数的实现:即为调用 msyscall(SYS_mem_alloc, envid, va, perm) 。这里的 msyscall 是一个汇编函数,在文件 user/lib/syscall_swap.S 中, msyscall 函数本身很简单,也就是调用两个汇编指令:

1
2
syscall
jr ra

在这个时候,tf中的内容为: $a0 = SYS_mem_alloc , $a1 = envid , $a2 = va , $a3 = perm (如果参数超过4个,则((int *)sp + n) 就是第n个参数) 。jr ra 负责实现在调用完内核态的函数之后回到之前的用户的位置。

由于在调用 msyscall 的时候已经传入了参数,所以在接着调用 syscall 的时候,当前的 Trapframe 中就保存着需要的参数,CPU会因为 syscall 的调用而进入中断。此时 :CPU 会将CP0中的Cause寄存器的中 ExcCode 字段的值置为8 ,同时CPU 强制将 PC(程序计数器)跳转到操作系统的异常处理统一入口也就是地址为0x80000180,这一步是CPU本身自己完成的行为。而在 kernel.lds 中,我们设置了

1
2
3
4
. = 0x80000180;
.exc_gen_entry : {
*(.text.exc_gen_entry)
}

这将异常处理函数 exc_gen_entry 放在了CPU自动跳转的地址,也就是出现中断的时候会自动进入这个函数。在 kern/entry.S中,这个函数用汇编语言进行编写:

1
2
3
4
5
6
7
8
9
10
.section .text.exc_gen_entry
exc_gen_entry:
SAVE_ALL
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
lw t0, exception_handlers(t0)
jr t0

代码细节不多说明,由于之前CPU将CAUSE寄存器的 ExcCode 置为8,这里会跳转到 exception_handlers[8] 这个函数的位置。在kern/traps.c中:

1
2
3
4
5
6
7
8
9
void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved,
[0] = handle_int,
[2 ... 3] = handle_tlb,
#if !defined(LAB) || LAB >= 4
[1] = handle_mod,
[8] = handle_sys,
#endif
};

可以看到8号对应的是 handle_sys 函数 ,这个函数定义在 kern/genex.S 中的一行 : BUILD_HANDLER sys do_syscall

1
2
3
4
5
6
7
8
9
.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
move a0, sp
addiu sp, sp, -8
jal \handler
addiu sp, sp, 8
j ret_from_exception
END(handle_\exception)
.endm

从这里可以看出,通过这个macro定义,这个 handle_sys 又调用了函数 do_syscall ,这个函数在文件 kern/syscall_all.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void do_syscall(struct Trapframe *tf) {
int (*func)(u_int, u_int, u_int, u_int, u_int);
int sysno = tf->regs[4];
if (sysno < 0 || sysno >= MAX_SYSNO) {
tf->regs[2] = -E_NO_SYS;
return;
}
tf->cp0_epc += 4 ;
func = syscall_table[sysno];
u_int arg1 = tf->regs[5];
u_int arg2 = tf->regs[6];
u_int arg3 = tf->regs[7];
u_int arg4, arg5;
u_int sp = tf->regs[29];
arg4 = *(u_int *)(sp + 16);
arg5 = *(u_int *)(sp + 20);
int res = func(arg1, arg2, arg3, arg4, arg5);
tf->regs[2] = res ;
}

主要逻辑就是从tf中取出参数,然后调用 syscall_table 中的对应 sysno = tf->regs[4] (也就是 $a0 )对应的系统调用函数。

所以,我们首先有函数表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kern/syscall_all.c */
void *syscall_table[MAX_SYSNO] = {
[SYS_putchar] = sys_putchar,
[SYS_print_cons] = sys_print_cons,
[SYS_getenvid] = sys_getenvid,
[SYS_yield] = sys_yield,
[SYS_env_destroy] = sys_env_destroy,
[SYS_set_tlb_mod_entry] = sys_set_tlb_mod_entry,
[SYS_mem_alloc] = sys_mem_alloc,
[SYS_mem_map] = sys_mem_map,
[SYS_mem_unmap] = sys_mem_unmap,
[SYS_exofork] = sys_exofork,
[SYS_set_env_status] = sys_set_env_status,
[SYS_set_trapframe] = sys_set_trapframe,
[SYS_panic] = sys_panic,
[SYS_ipc_try_send] = sys_ipc_try_send,
[SYS_ipc_recv] = sys_ipc_recv,
[SYS_cgetc] = sys_cgetc,
[SYS_write_dev] = sys_write_dev,
[SYS_read_dev] = sys_read_dev,
};

然后有对应的函数:

1
2
3
4
5
6
7
8
9
10
11
/* kern/syscall_all.c */
int sys_mem_alloc(u_int envid, u_int va, u_int perm) {
struct Env *env;
struct Page *pp;
if( is_illegal_va(va) )
return -E_INVAL ;
int ret ;
if( (ret = envid2env(envid, &env, 1)) != 0) return ret;
if((ret = page_alloc(&pp)) != 0) return ret ;
return page_insert(env->env_pgdir, env->env_asid, pp, va, perm);
}

这样就实现了从用户态 -> 内核态的系统调用的实现。

fork 中的细节

在lab4中,我们除了实现了系统调用,ipc通讯之外,我们还实现了fork 。

直接结合代码来看吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int fork(void) {  
u_int child;
u_int i;
if (env->env_user_tlb_mod_entry != (u_int)cow_entry) {
try(syscall_set_tlb_mod_entry(0, cow_entry));
}
child = syscall_exofork();
if (child == 0) {
env = envs + ENVX(syscall_getenvid());
return 0;
}
for (i = 0; i < VPN(USTACKTOP); i++) {
if ((vpd[i >> 10] & PTE_V) && (vpt[i] & PTE_V)) {
duppage(child, i);
}
}
syscall_set_tlb_mod_entry(child, cow_entry);
syscall_set_env_status(child, ENV_RUNNABLE);
return child;
}

这里其实有两个关键点

  1. 我们将父子进程的 env_user_tlb_mod_entry 都设置为了函数 cow_entry 的地址。
  2. 对于子进程的所有va,我们将其映射到了其父亲进程对应的物理页上。

这里其实有好几个点值得思考:

  1. 父子进程对物理页的具体使用
  2. 为什么fork之后会返回两个值
  3. 当出现父子进程运行同时对一个物理页进行写的时候这样的异常的处理流程

首先,第一点其实在指导书中说的很清楚,我这里直接扒下来了:
具体实现自己看看代码就明白了,其实很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前文中,我们知道了在调用fork 后,子进程会继承父进程地址空间中的代码段和数据段等
内容。由于在fork 后,父子进程将成为相互独立的两个进程,因此两个进程对于其内存的修改
应该是互不影响的。
如果我们在fork 时将父进程地址空间中的内容全部复制到新的物理页,将会消耗大量的物
理内存。而这些物理内存中,如代码段部分,父子进程通常不会对其进行写入。对于这样的页面,
我们希望能够避免对它们进行复制,从而可以节省物理内存。
为了父子进程能够共用尽可能多的物理内存,我们希望引入一种写时复制(Copy-on-write,
COW)机制:
1.在fork 时,只需将地址空间中的所有可写页标记为写时复制页面。
2.根据标记,在父进程或子进程对写时复制页面进行写入时,能够产生一种异常。
3.操作系统处理异常如下。
(a)为当前进程试图写入的虚拟地址分配新的物理页面。
(b)新的页面复制原页面的内容。
(c)返回用户程序。
4.处理完成后即可对新分配的物理页面进行写入。
5.这种机制使得我们可以最大限度的节省物理内存,减少了不必要的复制。

其次,第二点,为什么在fork之后会返回两个值。这个其实在指导书中也有说明,但是却花费了我大量时间去理解,这里我用自己的表述进行说明:

fork() 函数中,如果省去和返回值无关的内容,其实就变成了:

1
2
3
4
5
6
7
8
int fork(void) {  
u_int child;
child = syscall_exofork();
if (child == 0) {
return 0;
}
return child;
}

很显然这里需要结合函数 syscall_exofork ,根据上面的系统调用流程快速找到内核态的具体实现:位于 kern/syscall_all.c

1
2
3
4
5
6
7
8
9
10
int sys_exofork(void) {
struct Env *e;
int res ;
if((res = env_alloc(&e, curenv->env_id)) != 0) return res ;
e->env_tf = *((struct Trapframe *)KSTACKTOP - 1) ;
e->env_tf.regs[2] = 0 ;
e->env_status = ENV_NOT_RUNNABLE ;
e->env_pri = curenv->env_pri ;
return e->env_id;
}

这个函数其实很简单,创建一个进程,将这个进程的上下文变得和当前的 Trapframe 一样(也就是复制父进程的上下文),将regs[2] 也就是 $v0 设置成 0 ,返回这个进程的 env_id 就结束了。将这个放到 fork 中集中一起看,我们就可以理解为什么这个fork函数会返回两个值了:

首先:运行fork的时候,对于父进程来说,很显然 child 返回的是创建的子进程的id , 不会进入 if 分支,直接就出来了,返回child(子进程的env_id)。这是没有争议的第一个返回值。而在这个地方,从MIPS的角度来看,其实对于父进程而言,这个child的值的获得是这样的一个流程: 执行 sys_exofork ,运行到 return e->env_id 的时候,将 $v0 的值置为 e->env_id , 所以在进行 child = syscall_exofork() 的时候,实际上是将trapframe中的$v0的值赋给child

其次,关键点在于,在fork的过程中,我们创建了一个和父进程有一模一样的上下文的子进程,并且,我们将子进程设置成 ENV_RUNNABLE 并将其放进了调度队列中,很显然,子进程也会和父进程同步运行。但是注意一点:在进行系统调用的函数 sys_exofork 中, 我们有 e->env_tf.regs[2] = 0 ; 对于一个进程而言, $v0 寄存器是存储返回值的地方。
所以对于子进程而言,从他的视角看是这样的: 被创建成功并加入队列中,我将从父进程执行 syscall_exofork() 返回的位置继续执行。结合上面所说的MIPS细节,对于子进程而言,他继续运行得到的 child 的值就是0 !就会进入这个 if 分支中,就会返回0。

最后一点,对于出现当用户程序写入一个在TLB 中被标记为不可写入(无PTE_D)的页面时,MIPS 会陷入页写入异常(TLB Mod) ,区别于 tlb_miss 这里的异常不是常规的缺页中断(TLB 缺失异常),这里出现的异常是:因为子进程和父进程共享一个物理页,而这个时候其中一个要对这个页进行写操作,因为实际上父进程和子进程两个应该是独立的,不应该相互影响,所以这个时候需要为子进程重新分配一个新的物理页。这个和普通的 tlb_miss 的不同在于,这个的异常处理的时候,需要把子进程的新的物理页的内容全部复制成之前的内容。
为了实现这一点,这里设置了一个新的异常 tlb_mod ,当出现这个异常的时候,CAUSEExcCode 被设置为 1 ,对应异常处理函数中的 handle_mod 。这个异常处理和其他的异常处理不同的地方在于:具体解决这个异常的函数其实不在内核态,相反,具体的处理位于 user/lib/fork.c 中的 cow_entry 函数。
我们在 fork 函数中,我们有一步 syscall_set_tlb_mod_entry(child, cow_entry); 这就是将父子进程的 env_user_tlb_mod_entry 都设置为了函数 cow_entry 的地址,而在 kern/tlbex.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void do_tlb_mod(struct Trapframe *tf) {
struct Trapframe tmp_tf = *tf;
if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) {
tf->regs[29] = UXSTACKTOP;
}
tf->regs[29] -= sizeof(struct Trapframe);
*(struct Trapframe *)tf->regs[29] = tmp_tf;
Pte *pte;
page_lookup(cur_pgdir, tf->cp0_badvaddr, &pte);
if (curenv->env_user_tlb_mod_entry) {
tf->regs[4] = tf->regs[29];
tf->regs[29] -= sizeof(tf->regs[4]);
tf->cp0_epc = curenv->env_user_tlb_mod_entry ;
} else {
panic("TLB Mod but no user handler registered");
}
}

我们将 tf->cp0_epc = curenv->env_user_tlb_mod_entry ; 这使得在运行完这个 do_tlb_mod 之后会根据 epc 的值跳转到函数 cow_entry 中,在 cow_entry 中具体实现对物理页的巴拉巴拉操作。这样就成功的实现了在内核态调用用户态函数,来实现这个特殊的异常处理。

  • Title: 系统调用流程
  • Author: Connor
  • Created at : 2026-05-12 15:21:52
  • Updated at : 2026-05-12 15:28:29
  • Link: https://redefine.ohevan.com/2026/05/12/syscall/
  • License: This work is licensed under CC BY-NC-SA 4.0.
On this page
系统调用流程