关于操作系统:从零开始写-OS-内核-系统调用

6次阅读

共计 6743 个字符,预计需要花费 17 分钟才能阅读完成。

系列目录

  • 序篇
  • 筹备工作
  • BIOS 启动到实模式
  • GDT 与保护模式
  • 虚拟内存初探
  • 加载并进入 kernel
  • 显示与打印
  • 全局描述符表 GDT
  • 中断解决
  • 虚拟内存欠缺
  • 实现堆和 malloc
  • 第一个内核线程
  • 多线程运行与切换
  • 锁与多线程同步
  • 进入用户态
  • 过程的实现
  • 零碎调用
  • 简略的文件系统
  • 加载可执行程序
  • 键盘驱动
  • 运行 shell

零碎调用

接上一篇过程的实现,本篇将开始真正地创立 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.h 和 syscall.c,这里是用户层接口;
  • syscall_trigger.S,这是中断触发和传参的实现;

先看 syscall.c 中的用户层接口,这是用户间接调用的 syscall 函数,就是相似咱们平时 Linux 里用到的:

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

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

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 是带了三个参数的:

%macro DEFINE_SYSCALL_TRIGGER_3_PARAM 2
  [GLOBAL trigger_syscall_%1]
  trigger_syscall_%1:
    push ebx

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

    pop ebx
    ret
%endmacro

DEFINE_SYSCALL_TRIGGER_3_PARAM   exec,  SYSCALL_EXEC_NUM

咱们应用 ecxedxebx 顺次传递 trigger_syscall_exec 的三个参数。留神 ebx 这里做了 push 保留,因为依照 x86 的标准(calling convention),ebxcallee-saved 寄存器,须要被动保留和复原。

筹备好寄存器和传参,接下来 trigger 函数会应用 int 0x80 触发中断,这个中断就是零碎调用的对立入口,而后进入 kernel 的解决流程。

kernel 解决 syscall

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

  • syscall_wrapper.S 是 syscall 解决的对立入口;
  • syscall_impl.h 和 syscall_impl.c 是真正的各个 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 和一般中断的异同点。在此基础上,咱们实现了 syscall 中最有挑战的 fork,心愿它能帮忙你粗浅地了解 syscall 的进入和返回机制。

正文完
 0