接下来结合具体的代码讲解 OS 是如何实现系统调用的。

什么是系统调用?

系统调用是操作系统提供给上层应用的接口,应用程序通过系统调用请求操作系统提供的服务。在 C 语言中,我们可以使用系统调用来执行各种操作,如读写文件、创建进程等。以下是一个使用系统调用来读取文件的例子:

#include <unistd.h>
#include <fcntl.h>

int main() {
    char buffer[128];
    int fileDescriptor = open("example.txt", O_RDONLY);
    if (fileDescriptor < 0) {
        return -1;
    }
    size_t bytesRead = read(fileDescriptor, buffer, sizeof(buffer) - 1);
    if (bytesRead >= 0) {
        buffer[bytesRead] = '\0'; // Null terminate the string
        write(1, buffer, bytesRead); // Write to stdout
    }
    close(fileDescriptor);
    return 0;
}

在这个例子中,我们首先使用 open 系统调用打开一个文件。然后,我们使用 read 系统调用从文件中读取数据,并将数据存储在 buffer 中。最后,我们使用 write 系统调用将读取的数据写入到标准输出(stdout)。

系统调用的过程

在 JOS 内核中,系统调用是通过中断机制实现的。特别的,使用 int $0x30 指令来触发系统调用中断,这个中断的中断向量号是 48(0x30),对应的常量是 T_SYSCALL

下面是一些具体的汇编指令:

movl $num, %eax  ; 将系统调用编号放入eax寄存器
movl $a1, %edx   ; 将第一个参数放入edx寄存器
movl $a2, %ecx   ; 将第二个参数放入ecx寄存器
movl $a3, %ebx   ; 将第三个参数放入ebx寄存器
movl $a4, %edi   ; 将第四个参数放入edi寄存器
movl $a5, %esi   ; 将第五个参数放入esi寄存器
int $0x30        ; 执行中断指令,触发系统调用

这里,$num 是系统调用的编号,$a1$a5 是系统调用的参数。int $0x30 是触发系统调用的中断指令,0x30 是中断向量号,对应的常量是 T_SYSCALL

当用户程序需要进行系统调用时,它会将系统调用的编号放入 %eax 寄存器,将最多五个参数分别放入 %edx%ecx%ebx%edi%esi 寄存器,然后执行 int $0x30 指令。这个指令会触发一个中断,导致处理器切换到内核模式并跳转到中断处理程序。

在内核中,需要设置一个中断描述符来处理这个中断。中断描述符定义了当中断发生时处理器应该跳转到的地址,以及一些其他的属性,如特权级别等。对于系统调用中断,需要设置的特权级别应该允许用户程序触发这个中断。

系统调用处理完毕后,内核会将返回值放入 %eax 寄存器,然后返回到用户程序。这样,用户程序就可以从 %eax 寄存器中获取系统调用的返回值。这是系统调用的一种常见机制,用于将结果返回给用户程序。

软件中断和硬件中断

JOS 内核使用 int $0x30 指令来触发系统调用中断。这个中断是由用户程序生成的,而不是由硬件生成的(如设备完成操作或发生错误),因此不会与硬件中断混淆。

在计算机系统中,有许多中断是由硬件生成的。这些中断通常是由某种硬件事件触发的,例如:

  1. 定时器中断:当系统的定时器到达预设的时间时,会触发一个中断。这种中断通常用于实现时间共享,使得操作系统可以定期从一个任务切换到另一个任务。

  2. I/O 中断:当输入/输出设备完成了一个操作(例如,硬盘完成了数据的读取或写入),它会触发一个中断,通知 CPU 数据已经准备好或者已经被成功写入。

  3. 错误中断:当硬件发生错误时(例如,内存错误或设备故障),会触发一个中断,通知操作系统需要处理这个错误。

这些都是由硬件生成的中断的例子。与之相对,int $0x30 是由用户程序显式触发的,用于进行系统调用,因此不会与硬件中断混淆。

如何实现系统调用?

用户进程通过调用系统调用来请求内核为它们执行操作。当用户进程调用一个系统调用时,处理器进入内核模式,处理器和内核协作保存用户进程的状态,内核执行适当的代码以执行系统调用,然后恢复用户进程。

在代码中,系统调用的实现主要涉及到两个文件:kern/trap.cinc/mmu.h

inc/mmu.h 文件中,定义了系统调用的中断向量号 T_SYSCALL,这是系统调用的唯一标识符。

#define T_SYSCALL 0x30

kern/trap.c 文件中,系统调用的实现主要在 trap_init 函数和 trap_dispatch 函数中。

trap_init 函数中,设置了系统调用的门描述符。其中,SETGATE 宏用于设置门描述符,第一个参数是门描述符的地址,第二个参数表示这是一个中断门,第三个参数是段选择器,第四个参数是中断处理程序的地址,第五个参数是特权级别。

SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);

trap_dispatch 函数中,当中断向量号为 T_SYSCALL 时,会调用 syscall 函数处理系统调用。

case T_SYSCALL:
   tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax,
        tf->tf_regs.reg_edx,
        tf->tf_regs.reg_ecx,
        tf->tf_regs.reg_ebx,
        tf->tf_regs.reg_edi,
        tf->tf_regs.reg_esi);
    return;

总的来说,系统调用的实现主要包括设置系统调用的门描述符和处理系统调用的函数。

syscall 函数的参数是从 Trapframe 结构体中获取的,这些参数是在发生系统调用时保存的寄存器的值。这些寄存器的值包含了系统调用的编号和参数。

系统调用的编号通常保存在 eax 寄存器中,而系统调用的参数则保存在其他寄存器中。在这个例子中,系统调用的参数保存在 edxecxebxediesi 寄存器中。

syscall 函数会根据系统调用的编号,调用相应的处理函数,并将系统调用的参数传递给处理函数。处理函数执行完毕后,会返回一个结果,这个结果会被保存在 eax 寄存器中,然后返回给用户程序。

这样做的目的是为了实现用户程序与操作系统内核之间的交互。用户程序通过系统调用请求操作系统提供的服务,如读写文件、创建进程等。操作系统在完成用户程序的请求后,会将结果返回给用户程序。

syscall

接下来实现 syscall ,下面这段代码是操作系统内核中处理系统调用的部分。函数 syscall 是一个分发函数,它根据系统调用的编号(syscallno),调用相应的处理函数。

int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	int32_t ret;
	switch (syscallno) {
		case SYS_cputs:
			sys_cputs((char *)a1, (size_t)a2);
		ret = 0;
		break;
		case SYS_cgetc:
			ret = sys_cgetc();
		break;
		case SYS_getenvid:
			ret = sys_getenvid();
		break;
		case SYS_env_destroy:
			ret = sys_env_destroy((envid_t)a1);
		break;
		default:
			return -E_INVAL;
	}

	return ret;
}

函数的参数 syscallno 是系统调用的编号,a1a5 是系统调用的参数。这些参数是在发生系统调用时保存的寄存器的值。

在函数体中,首先定义了一个 int32_t 类型的变量 ret,用于保存系统调用的返回值。

然后,使用 switch 语句根据系统调用的编号调用相应的处理函数。例如,当 syscallnoSYS_cputs 时,调用 sys_cputs 函数处理系统调用。

sys_cputs 函数的作用是将用户程序的字符串输出到控制台。它的参数是一个指向字符串的指针和字符串的长度。这两个参数分别通过 a1a2 传递给 sys_cputs 函数。

其他的 case 分支也是类似的,它们分别处理不同的系统调用。

最后,如果 syscallno 不匹配任何已知的系统调用编号,那么返回错误码 -E_INVAL,表示无效的系统调用。

在所有的 case 分支中,都会设置 ret 的值,然后在函数的最后返回 ret。这个返回值会被保存在 eax 寄存器中,然后返回给用户程序。