关于操作系统:从零开始写-OS-内核-多线程切换

43次阅读

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

系列目录

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

筹备

接上一篇,咱们启动了第一个 thread,如同只是让一个一般的函数在一个全新的 stack 上运行起来而已。作为一个真正的操作系统,须要能运行调度多个 threads。创立多个 threads 的过程很简略,问题是如何让它们交替切换运行起来。回到上一篇提到的对于 thread 的两个外围形成因素:

  • 代码
  • stack

本篇将进入多线程的世界。在这里会展现,两个 thread 如何实现 stack 的转换,并且在 stack 转换的同时,代码的执行流也主动产生了切换。并且基于 multi-threads 的切换能力,咱们将初步构建出一个调度器 scheduler

线程切换

首先回顾上一篇,thread 启动后的 stack 情况,程序运行在图中浅蓝色局部的 stack 区域内:

如果没有外界触发,程序将始终在这里运行上来,不可能产生 thread 切换,即便在别的中央另外有一个 thread,领有它本人独立的 stack;因而这里须要一个触发者,这就是时钟 interrupt,在中断解决一篇的最初,咱们尝试关上了时钟 interrupt,那里的 handler 只是简略的打印函数。当初咱们须要在外面实现 threads 切换。

构想程序正在上图的 stack 运行,这时产生了时钟 interrupt,CPU 将主动进入中断解决,回顾一下中断解决一篇的内容,此时 stack 会变成这样:

通过 CPU 和 kernel 的一系列压栈操作,程序执行流程大略是这样:

isr32 (32 是时钟 interrupt 中断号)
  --> isr_common_stub
    --> isr_handler
      --> timer_callback

最终来到 timer_callback 函数:

struct task_struct* crt_thread;
struct task_struct* next_thread;

static void timer_callback(isr_params_t regs) {
  // For only 2 threads switch.
  struct task_struct* old_thread = crt_thread;
  struct task_struct* new_thread = next_thread;
  
  // swap, for next time switch.
  crt_thread = new_thread;
  next_thread = old_thread;

  context_switch(old_thread, new_thread);
}

如果你不记得 task_struct 定义了,它就是 thread 的形容构造,这里再贴一下:

struct task_struct {
  uint32 kernel_esp;
  uint32 kernel_stack;
  
  uint32 id;
  // ...
};

下面的示例代码应该不难理解,假设咱们只有 2 个 threads,那么在 timer_callback 里就是实现它们两者的相互切换。

要害局部是最初一行 context_switch 函数,它实现了两个 thread 的切换,它其实分了高低两半局部:

上半局部实现对以后 thread 的 context 保留:

context_switch:
  push eax
  push ecx
  push edx
  push ebx
  push ebp
  push esi
  push edi

  mov eax, [esp + 32]
  mov [eax], esp

上班局部,实现对下一个待运行 thread 的复原:

  ; jump to next thread's kernel stack
  ; and recover its execution
  mov eax, [esp + 36]
  mov esp, [eax]

resume_thread:
  pop edi
  pop esi
  pop ebp
  pop ebx
  pop edx
  pop ecx
  pop eax

  sti
  ret

留神 esp + 32esp + 36 是拿到 context_switch 函数的两个参数,即待切换的新老两个 thread:

void context_switch(old_thread, new_thread);

参数是指向 task_struct 的指针,而 task_struct 的第一个字段就是 kernel_esp,也就是说这里将 threads 产生切换时的 esp,保留在了 kernel_esp 这个字段里;待下一次 thread 复原运行时,再读出 kernel_esp 找到它被切走前的 最初一瞬间的 esp,持续运行。

而且通过 stack 转换后,代码的执行流也主动复原到了下一个 thread 原来的执行流上,事实上它们原本就是同一个流。因为待切换的 thread,之前被切走时,也是通过 context_switch 函数,push 了所有通用寄存器,保留 esp,而后被切走,当初则是在雷同的指令地位原地复原,继续执行。

所以这里须要了解的最要害的一点是,context_switch 函数的高低两半局部的执行,是分属于新老两个 thread 的。老 thread 执行完前半段,就被切走挂起;它要等到下次再被调度时,原地复原,持续运行后半段,拼成一个残缺的 context_switch 执行流;而后向上层层 return,最终回到时钟 interrupt 之前打断它的那个中央持续运行。

切换到新创建的 thread 上

下面探讨的状况中,待运行的 next thread 是一个之前运行过,被切走挂起的 thread,所以它的 stack 布局是和以后运行的 thread 是一样的,都是来到 context_switch 函数的运行 stack。但如果待运行的 thread 是一个新创建的呢?如果回顾上一章的内容,就会发现这里在设计上是无缝连接的。

上图是新创建的 thread 的 stack,它恰好初始化了一个 switch stack,并且将 kernel_esp 字段指向了该 stack,因而从 context_switch 函数的下半局部开始运行,它就能立即正确初始化 esp,并且 pop 所有通用寄存器,这和第一个 thread 启动的形式是统一的。

也就是说,第一个 thread,是从 resume_thread 开始启动;而前面的所有 thread,都是通过 context_swith 以雷同的形式完满启动。

线程调度器

有了 2 个 threads 的切换能力,咱们实际上能够创立更多的 threads,并且开始构建一个调度器 scheduler。最简略调度模式就是所有 threads 顺次运行,周而复始,所以这首先须要一个链表来保留所有的 threads。

我在 src/utils/linked_list.c 实现了一个链表构造,它是一个通用链表,每个节点保留的是指向理论对象的指针,在咱们这里就是 task_struct*,具体的实现能够参考代码,比较简单。

struct linked_list_node {
  type_t ptr;
  struct linked_list_node* prev;
  struct linked_list_node* next;
};
typedef struct linked_list_node linked_list_node_t;

struct linked_list {
  linked_list_node_t* head;
  linked_list_node_t* tail;
  uint32 size;
};
typedef struct linked_list linked_list_t;

因而咱们能够定义所有待运行 threads 的链表,即 ready_tasks,这外面所有的 thread 状态都是 TASK_READY,也就是就绪态,它们是能够被下一次调度运行的 task:

static linked_list_t ready_tasks;

因而 scheduler 的逻辑很简略,这里以伪代码展现:

next_thread = Fetch head from ready_tasks queue;
set next_thread.status = TASK_RUNNING;
set crt_thread.status = TASK_READY;
Enqueue crt_thread to tail of ready_tasks queue;

context_switch(crt_thread, next_thread);

总结

本篇实现了多线程运行和 scheduler 的根本框架,实际上到此为止,这个 kernel 曾经能够称得上是一个真正的 OS 了,它曾经具备了一个最简略的操作系统应有的根本骨架:

  • 内存治理
  • 中断解决
  • 工作治理

前面的工作,咱们将持续丰盛这个 kernel,使之更多的性能和更好的用户交互能力。

正文完
 0