从零开始写 OS 内核 - 系统调用

navi
English

系列目录

系统调用

接上一篇进程的实现,本篇将开始真正地创建 process,使用到的正是我们熟悉的 fork 系统调用,所以首先需要将系统调用(system call)的框架搭建出来。

system call 的概念不必赘述,它是 kernel 为 user 提供的对外功能接口,是 user 态主动请求调用 kernel 功能的主要方式。既然是从 user 到 kernel 态,那么就需要通过中断的方式触发。仿照 Linux 32-bit 系统的经典方式,我们也将会使用 int 0x80 的软中断进入 syscall

由于 syscall 是给用户使用的,所以它的整个实现包括了两个部分:

  • user 部分:统一的函数接口,底层是通过 int 0x80 触发中断;
  • kernel 部分:类似正常的中断处理;

user 接口

首先来看 user 部分的实现,注意这部分的代码是编译链接入 user 程序,而不是 kernel 的,它会以类似标准库的形式打包,在后面我们编写 user 程序时会 link 进去。

本节的代码主要是以下几个文件,按照从顶往下的调用关系:

先看 syscall.c 中的用户层接口,这是用户直接调用的 syscall 函数,就是类似我们平常 Linux 里用到的:

int32 fork();
int32 exec(char* path, uint32 argc, char* argv[]);

它们的底层调用了由 syscall_trigger.S 提供的 trigger 函数,它们是真正触发 syscall 中断和传递参数的地方:

int32 fork() {
  return trigger_syscall_fork();
}

int32 exec(char* path, uint32 argc, char* argv[]) {
  return trigger_syscall_exec(path, argc, argv);
}

trigger_syscall_xxx 实现都定义在 syscall_trigger.S 里。

syscall 使用统一的 int 0x80 中断触发,但是因为有很多 syscall,所以每个 syscall 都一个 number,例如:

SYSCALL_FORK_NUM   equ  1
SYSCALL_EXEC_NUM   equ  2

另外,syscall 和一般的中断有个区别就是需要传参,为此根据参数的个数,我们在 syscall_trigger.S 里定义了多个 macro 作为模板,例如不带参数的 syscall:

%macro DEFINE_SYSCALL_TRIGGER_0_PARAM 2
  [GLOBAL trigger_syscall_%1]
  trigger_syscall_%1:
    mov eax, %2
    int 0x80
    ret
%endmacro
        
DEFINE_SYSCALL_TRIGGER_0_PARAM   fork,   SYSCALL_FORK_NUM

这样实际上就得到了 fork 的底层 trigger 函数:

[GLOBAL trigger_syscall_fork]
trigger_syscall_fork:
  mov eax, SYSCALL_FORK_NUM
  int 0x80
  ret

syscall 本质上都会带参数,最起码的,我们会使用 eax 保存 syscall number。如果 syscall 本身也有参数,那么还会用到其它寄存器,例如 ecxedxebx 等,当然这都是人为规定的。

例如 exec 是带了三个参数的:

trigger_syscall_exec:
  push ebx

  mov eax, %2
  mov ecx, [esp + 8]
  mov edx, [esp + 12]
  mov ebx, [esp + 16]
  
  int 0x80

  pop ebx
  ret

我们使用 ecxedxebx 依次传递 trigger_syscall_exec 的三个参数。注意 ebx 这里做了 push 保存,因为按照 x86 的规范(calling convention),ebxcallee-saved 寄存器,需要主动保存和恢复。

准备好寄存器和传参,接下来 trigger 函数会使用 int 0x80 触发中断,这个中断就是系统调用的统一入口,然后进入 kernel 的处理流程。

kernel 处理 syscall

本节的主要代码以下文件:

当然在此之前,syscall 是一个中断,所以首先要注册 0x80 中断的 handler 函数,在 src/interrupt/interrupt.c 中,入口是 syscall_entry 函数:

set_idt_gate(SYSCALL_INT_NUM,
             (uint32)syscall_entry,
             SELECTOR_K_CODE,
             IDT_GATE_ATTR_DPL3);

来看 syscall_entry 函数,它和一般中断的入口函数基本一样,也是分为两部分。

上半部分是保存用户的 context,包括所有的通用寄存器,segment 寄存器等,然后调用 syscall_handler 进入真正的 syscall 分发处理。

syscall_entry:
  ; push dummy to match struct isr_params_t
  push byte 0
  push byte 0
  ; save common registers
  pusha
  ; save original data segment
  mov cx, ds
  push ecx
  ; load the kernel data segment descriptor
  mov cx, 0x10
  mov ds, cx
  mov es, cx
  mov fs, cx
  mov gs, cx

  sti  ; allow interrupt during syscall
  call syscall_handler

下半部分是返回,也是和中断返回类似,恢复上面保存的所有寄存器。不过有一个要注意,eax 不可以 pop,因为 syscall 是有返回值的,正是 eax 保存了 syscall_handler 返回值:

syscall_exit:
  ; recover the original data segment.
  ; Do NOT use eax because it's the syscall ret value!
  pop ecx
  mov ds, cx
  mov es, cx
  mov fs, cx
  mov gs, cx

  pop  edi
  pop  esi
  pop  ebp
  pop  esp
  pop  ebx
  pop  edx
  pop  ecx
  ; skip eax because it is used as return value
  ; for syscall_handler
  add  esp, 4

  ; pop dummy values
  add esp, 8

  ; pop cs, eip, eflags, user_ss, and user_esp by processor
  iret

syscall_handler 是真正的 syscall 分发处理函数,它从参数 eax 中拿到 syscall number,找到对应的 syscall 的处理实现:

int32 syscall_handler(isr_params_t isr_params) {
  // syscall num saved in eax.
  // args list: ecx, edx, ebx, esi, edi
  uint32 syscall_num = isr_params.eax;

  switch (syscall_num) {
    case SYSCALL_FORK_NUM:
      return syscall_fork_impl();
    case SYSCALL_EXEC_NUM:
      return syscall_exec_impl((char*)isr_params.ecx,
                               isr_params.edx,
                               (char**)isr_params.ebx);
    default: PANIC();
  }
}

注意到这里 syscall_handler 和普通的中断处理函数一样,也是将整个中断 stack 上 push 进来的数据作为一整个 isr_params 结构作为参数:

如果是普通的中断,这个 stack 上保存的通用寄存器的值是用于保存并恢复中断发生之前的 context 信息的;不过在 syscall 这里,它们的作用发生了变化,有一部分是实际上是作为 syscall 的参数传递,在上面的 syscall_handler 里它们被取了出来给各个 syscall 的处理函数使用。

回想一下,这些用于传参的寄存器值是在哪里被设置的呢?是在 user 端触发 syscall 的各个 trigger_syscall_xxx 函数里,在那里我们将 user 调用 syscall 时最初的参数赋值给了各个寄存器:

trigger_syscall_exec:
  push ebx

  mov eax, %2
  mov ecx, [esp + 8]
  mov edx, [esp + 12]
  mov ebx, [esp + 16]
  
  int 0x80

  pop ebx
  ret

这里我们需要理清整个 syscall 的参数传递链:

  • 在 user 端的 trigger 部分,参数被保存在了各个通用寄存器里;
  • 触发中断,进入 kernel stack 后,这些寄存器的值被 push 进了中断 stack,封装在了 isr_params 结构中,最终给到 syscall_handler 函数;

同时我们注意到,如果传参用到了 callee-saved 寄存器,那么还会在 user stack 里先保存下它们的值,例如上面的 ebx。这实际上说明,user 的 context 保存和恢复过程中,有一部分寄存器是在 user stack 上由 trigger_syscall_xxx 主动完成的,而不是在进入中断以后,因为中断 stack 上保存的有些寄存器的值,后面将被用于 syscall 传参的,它们的值会被覆盖,所以必须提前在 user stack 上保存好。这也是 syscall 和普通中断不一样的地方。

这里面本质的原因是,syscall 是主动发起而不是像一般的中断那样不可预知的,所以它其实更像是一次普通的函数调用。 调用方(user)只要遵循 x86 的函数调用规范(calling convention),先从容地在自己的 stack 上保存用到 callee-saved 寄存器,然后就可以随意使用这些寄存器来传参,最后通过 int 0x80 触发中断,进入 kernel stack 处理。

fork 的实现

上面说了这么多都是 syscall 的框架,现在我们就来实现第一个 syscall:fork

syscall_handler 里,fork 被分发给了 syscall_fork_impl 函数,具体的实现是 process_fork 函数,它在 src/task/process.c 里定义。

相信你应该熟悉 Linux 下 fork 的使用方式:

int pid = fork();
if (pid > 0) {
  // parent process
} else if (pid == 0) {
  // child process
} else {
  // fork failed
}

很不幸,我们的第一个系统调用 fork 算是一个比较复杂的。 fork 会创建一个和父进程一样的新的子进程,它们都会从 fork 返回并继续执行,区别在于返回值。parent 进程会返回创建出来的 child 的 pid,而 child 进程会返回 0。

首先调用了 create_process 函数创建了一个全新的 process 结构,把相应的字段都初始化;然而注意 child 的 page directory 是从 parent 复制而来,这样它们就能共享虚拟内存空间:

pcb_t* create_process(char* name, uint8 is_kernel_process) {
  pcb_t* process = (pcb_t*)kmalloc(sizeof(pcb_t));
  memset(process, 0, sizeof(pcb_t));
  //...
  process->page_dir = clone_crt_page_dir();
}

然后到了最关键的一个函数 fork_crt_thread,即复制当前的 thread,它主要的功能是复制了当前的 kernel stack,然后将这个 stack 设置成一个新的 thread 启动时的样子,这样 child thread 等会儿就可以像一个新 thread 一样正常地启动起来。虽然它是第一次启动,但是看上去就好象是和 parent 一样,是从 fork 中返回的。

回顾一下 kernel 线程启动时的 stack:

stack 从 kernel_esp 位置开始,向上 pop 通用寄存器,然后以 start_eip 为入口跳转。这里我们将 child 线程的 start_eip 设置为 syscall_fork_exit

thread->kernel_esp = kernel_stack + KERNEL_STACK_SIZE
    - (sizeof(interrupt_stack_t) + sizeof(switch_stack_t));

switch_stack_t* switch_stack = (switch_stack_t*)thread->kernel_esp;
switch_stack->thread_entry_eip = (uint32)syscall_fork_exit;

syscall_fork_exit 这个函数,确切来说名字最好叫 syscall_fork_child_exit,是专门用于 fork 完毕后 child 进程返回用的,它和正常的 syscall 返回的不同之处在于通用寄存器的恢复部分:

  pop edi
  pop esi
  pop ebp
  ; Do NOT pop old esp!
  ; Child process is its own stack, not parent's.
  add esp, 4
  pop ebx
  pop edx
  pop ecx
  ; child process returns 0.
  mov eax, 0
  add esp, 4

espeax 做了特殊处理:

  • stack 上保存的 esp 的值,是 parent 的 esp,而 child 已经分配了自己的 stack 了,所以要跳过;
  • eax 作为 fork 的返回值,在 child 这里必须是 0;

接下来运行到 iret 即中断返回,这里 CPU 会恢复出 syscall 之前 user thread 的运行状态:

即 user thread 的 code + stack 信息:

  • code:保存在了 cs + eip
  • stack:保存在了 user_ss + user_esp

这部分信息是和 parent 的 stack 里内容是一样的,因为 child 的 kernel stack 是从 parent 复制过来的。这也是为什么 child 回到 user 态后,可以像 parent 一样,从 fork 后面的代码开始继续运行,好像 parent 给自己镜像了一个任务出来。当然它们的虚拟内存空间是隔离的,这用到了上一篇将的 copy-on-write 机制。

而 parent 在 fork_crt_thread 之后,完成对 child 进程创建的收尾工作,然后就返回了,返回值是刚创建出来的 child 进程的 pid:

// Create new process and fork current thread.
pcb_t* process = create_process(nullptr);  
tcb_t* thread = fork_crt_thread();
if (thread == nullptr) {
  return -1;
}

// Bind child thread to child process.
add_process_thread(process, thread);

// Add child thread to scheduler to run.
add_thread_to_schedule(thread);

// Parent should return the new pid.
return process->id;

可以看到,parent 是正常从 syscall 返回,而 child 的 kernel stack 被我们魔改过了,使之以 thread 第一次启动的方式运行起来,但需要注意两点:

  • 它的中断 stack 必须和 parent 保持一致,这样就中断返回时,就能恢复出和 parent 一样的 user thread 运行环境;所以 child 回到 user 态后,看上去就像是一个和 parent 一样的任务继续运行,这也是 fork 的本意;
  • 返回值必须是 0;

总结

本篇的内容还是有点多的,首先是 syscall 的框架实现,要能区分 user 端和 kernel 端分别的功能职责,以及 syscall 和普通中断的异同点,这里最重要的就是 register 和 stack 上数据的流转过程。在此基础上,我们实现了 syscall 中最有挑战的 fork,希望它能帮助你深刻地理解 syscall 的进入和返回机制。

阅读 304

naive programmer

508 声望
84 粉丝
0 条评论
你知道吗?

naive programmer

508 声望
84 粉丝
宣传栏