3

系列目录

准备

这个项目系列到这里差不多到一半了,前半部分的 segment,虚拟内存,中断等,其实可以看作只是准备工作。是的,我们花了大量时间去准备这些基石工作,以至于到现在,整个所谓的 kernel 好像仍然处于一种“静止“状态,可能已经让你觉得困倦了。从本篇开始,这个 kernel 将会真正地”动“起来,开始搭建一个操作系统应有的核心能力,那就是任务管理。

从用户的角度来讲,操作系统的本质功能就是为他们运行任务,否则就难以称之为操作系统了。这是一个复杂的工程,万事开头难,所以作为开始阶段的本篇,会尽可能地简单,从运行一个单线程开始。在后面的篇章中,会逐渐进入多线程切换与管理,同步与竞争等问题,以及最终来到更上层的进程管理。

线程

threadprocess 的相关概念应该不需要多解释了,都是老生常谈。在接下来的行文和代码中,我会将 task 等同于 thread,两者混用,都表示线程;而 process 则是进程。

操作系统调度的对象是 thread,也是接下来需要讨论和实现的核心概念。 也许 thread 听上去很抽象,从本质上来说,它可以归结为以下两个核心要素:

代码 + stack

代码控制它时间维度上的流转,stack 则是它空间维度的依托,这两者构成了 thread 运行的核心。

所以每个 thread 都有它自己的 stack,例如运行在 kernel 态的一堆 threads,大概是这样的格局:

每个 thread 运行在它自己的 stack 上,而操作系统则负责调度这些 threads 的启停。从本质上说,自从我们进入 kernel 的 main 函数运行到现在,也可以归为一个 thread,它是一个引导。再往后,操作系统将创建更多的 threads,并且 CPU 将会在操作系统的控制下,在这些 threads 之间来回跳转切换,其实质就是在这些 threads 各自所属代码(指令)和 stack 上进行跳转切换。

创建 thread

本篇代码主要在 src/task/thread.c,仅供参考。

首先建立 thread 结构 task_struct,或者叫 tcb_t,即 task control block

struct task_struct {
  uint32 kernel_esp;
  uint32 kernel_stack;
  
  uint32 id;
  char name[32];

  // ...
};
typedef struct task_struct tcb_t;

这里有两个关键字段,是关于这个 thread 的 stack 信息:

  • kernel_esp
  • kernel_stack

每个 thread 都以 page 为单位分配 kernel stack 空间,Linux 好像是 2 pages,所以我们也分配 2 pages,用 kernel_stack 字段指向它,这个字段后面不再变化:

#define KERNEL_STACK_SIZE  8192

tcb_t* init_thread(char* name,
                   thread_func function,
                   uint32 priority,
                   uint8 is_user_thread) {
  // ...
  thread = (tcb_t*)kmalloc(sizeof(struct task_struct));
  // ...
  uint32 kernel_stack = (uint32)kmalloc_aligned(KERNEL_STACK_SIZE);
  for (int32 i = 0; i < KERNEL_STACK_SIZE / PAGE_SIZE; i++) {
    map_page(kernel_stack + i * PAGE_SIZE);
  }
  memset((void*)kernel_stack, 0, KERNEL_STACK_SIZE);
  thread->kernel_stack = kernel_stack;
  
  // ...
}

注意这里分配了 2 pages 给 kernel_stack 后,立刻为它建立了 physical 内存的映射。这是因为 page fault 作为一个中断,它的处理是要在 kernel stack 上完成的,因此对 kernel stack 本身的访问不可以再触发 page fault,所以这里提前解决了这个问题。

另一个字段 kernel_esp,标识的是当前这个 thread 在 kernel stack 上运行的 esp 指针。目前是 thread 创建阶段,我们需要初始化这个 esp,所以首先需要对整个 stack 的版图做一个初始化。我们为 stack 定义如下结构:

struct switch_stack {
  // Switch context.
  uint32 edi;
  uint32 esi;
  uint32 ebp;
  uint32 ebx;
  uint32 edx;
  uint32 ecx;
  uint32 eax;

  // For thread first run.
  uint32 start_eip;
  void (*unused_retaddr);
  thread_func* function;
};

这个 stack 结构第一眼看上去可能比较奇怪,后面会慢慢展开解释。它既是 thread 第一次启动运行时的初始 stack,也是后面 multi-threads 上下文切换时的 stack,所以也可以叫 context switch stack,或者 switch stack。我们将它铺设到刚才分配的 2 pages 的 stack 空间上去:

switch stack 上方的虚线空间是预留的,这是以后作为返回用户空间用的 interrupt stack,暂时可以无视。目前你只需要知道它的结构定义为 interrupt_stack_t,也就是之前的 src/interrupt/interrupt.h 里定义的 isr_params 这个结构,你可以回顾一下中断处理这一篇,它是中断发生时的 CPU 和操作系统压栈,用于保存中断 context 的。

所以整个 stack 的初始化,就是在最上方分配了一个 interrupt stack + switch stack 结构:

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

于是 kernel_esp 就被初始化为上图标出的位置,实际上指向了 switch stack 这个结构。

接下来就是初始化这个 switch stack

switch_stack_t* switch_stack = (switch_stack_t*)thread->kernel_esp;

switch_stack->edi = 0;
switch_stack->esi = 0;
switch_stack->ebp = 0;
switch_stack->ebx = 0;
switch_stack->edx = 0;
switch_stack->ecx = 0;
switch_stack->eax = 0;

switch_stack->start_eip = (uint32)kernel_thread;
switch_stack->function = function;
  • 所有的通用寄存器初始化为 0,因为这是 thread 第一次运行;
  • start_eip 是 thread 入口地址,设置为 kernel_thread 这个函数;
  • function 是 thread 真正要运行的工作函数,它由 kernel_thread 函数来启动运行;
static void kernel_thread(thread_func* function) {
  function();
  schedule_thread_exit();
}

这里如果不明白的话可以先接着往下看 thread 的运行,然后再来回顾。

运行 thread

创建 thread,并且运行 thread:

void test_thread() {
  monitor_printf("first thread running ...\n");
  while (1) {}
}

int main() {
  tcb_t* thread = init_thread(
      "test", test_thread, THREAD_DEFAULT_PRIORITY, false);

  asm volatile (
    "movl %0, %%esp; \
    jmp resume_thread": : "g" (thread->kernel_esp) : "memory");
}

测试代码很简单,创建了一个 thread,它要运行的函数是 test_thread,仅仅是打印一下。

这里在 C 语言里用内联 asm 代码,触发了 thread 开始运行,来看一下它的原理。首先将 esp 寄存器赋值为该 thread 的 kernel_esp,然后跳转到 resume_thread 这个函数:

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

它其实是 context_switch 函数的下半部分,这个是用于 multi-threads 切换的,这个下一篇再讲。

来看 resume_thread 做的事情,对照图中的 kernel_esp 位置开始,运行代码:

  • 首先 pop 了所有通用寄存器,在 multi-threads 切换里它是用来恢复 thread 的 context 数据的,但是现在 thread 是第一次运行,所以它们这里全被 pop 成了 0;
  • 然后 ret 指令,使程序跳转到了 start_eip 处,它被初始化为为函数 kernel_thread,从这里 thread 正式开始运行,它的运行 stack 为右图中浅蓝色部分;

注意到 kernel_thread 函数,传入并运行了参数 function,这是 thread 真正的工作函数:

static void kernel_thread(thread_func* function) {
  function();
  schedule_thread_exit();
}

这里可能有几个问题需要解释一下:

[问题 1] 为什么不直接运行 function,而要在外面嵌套一层 kernel_thread 函数作为 wrapper?

因为 thread 运行结束后需要一个退出机制,函数 schedule_thread_exit 会完成 thread 的收尾和清理工作;schedule_thread_exit 函数不会返回,而是直接引导该 thread 消亡,然后切换到下一个待运行的 thread。关于 schedule_thread_exit 也会在下一篇中再细讲。

[问题 2] 图中灰色的 unused 部分是什么?

它是 kernel_thread 函数的返回值,实际上 kernel_thread 不会返回,因为函数 schedule_thread_exit 不会返回。这里 unused 仅仅是一个占位。


OK,至此我们的第一个 thread 就运行起来了,可以看到它的打印:

总结

本篇运行了第一个 thread,它做的事情其实比较简单,就是找了一块内存做 stack,然后在上面创造出了一个函数运行的环境,然后跳转指令到 thread 的入口处开始运行。可能有很多细节处还是雾里看花,不知其所以然,本篇都没有详细展开,例如:

  • 为什么要构建这样的一个 stack 布局?
  • thread 运行结束后的退出机制是什么样的?
  • 以及最重要的,thread 之间怎么切换运行?

这些都留待下一篇详解,待到下一篇完成后,结合这两篇的内容,应该会对 threaad 的运行机制有一个全面的认识。


navi
612 声望189 粉丝

naive