关于操作系统:从零开始写-OS-内核-进入用户态

46次阅读

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

系列目录

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

用户态线程

在后面几篇中,咱们曾经启动了 kernel 线程并实现了 multi-threads 调度运行。接下来咱们须要启动用户线程,毕竟这个 OS 是提供给用户应用的,未来的大多数 thread 也将是用户态的。

这里须要清晰一下 user 和 kernel 的 thread / stack 之间的关系,可能有的同学对此不足一个直观的认知。

  • 每一个 user thread 都会有 2 个 stack,别离是 user 空间的 stack 和 kernel 空间的 stack;
  • 失常状况下的 user thread,运行在 user stack 上;
  • 当产生中断时(包含 interrupt / exception / soft int),执行流跳转到该 thread 的 kernel stack 开始执行中断 handler,执行结束后再返回到 user stack 复原执行;

从 kernel thread 启动

须要明确一点,user thread 不是凭空出现的,实质上它依然须要从 kernel thread 开始运行,而后跳转到用户的 code + stack 上运行。所以这里首先回顾一下 kernel thread 启动的过程,在第一个内核线程一篇中具体解说过。这里最外围的工作是对 kernel stack 的初始化,咱们构建了如下图所示的一个 stack:

而后从 resume_thread 指令处开始运行,stack 初始地位从 kernel_esp 开始 pop,初始化了各个通用寄存器,并且通过 ret 指令跳转到函数 kernel_thread 开始运行。能够看到 kernel 线程最终是进入 kernel_thread 这个工作函数运行的,并且始终运行在 kernel stack 中(浅蓝色局部)。

如何转入 user 态

从 kernel 进入 user 态,这里要解决两个问题:

  • 如何跳转到 user 代码运行,这里须要变换 CPU 特权级,从 0 -> 3;
  • 如何跳转到 user stack,它在 3GB 以下 user 空间;

对于问题 1,须要揭示一下,这不是一个一般的 jmp 或者 call 指令就能够做到的。x86 的架构下 CPU 特权级升高的惟一方法是从中断返回,即 iret 指令;

对于问题 2,须要留神进入 user 空间的 stack,须要扭转 ss 寄存器的值,是之指向 user 的 data segment(你可能须要温习一下 segment 相干的常识,在全局描述符表 GDT 一篇中初始化过);

所以实质上用户 thread 的初始化就是模仿一次从中断返回的过程,如果你还记得中断解决一篇中的中断 stack 的构造:

中断 stack 由两局部形成,一部分是 CPU 主动 push 的 registers,一部分是咱们在中断 handler 启动时 push 的,整个中断 stack 用如下 struct 形容:

typedef struct isr_params {
  uint32 ds;
  uint32 edi, esi, ebp, esp, ebx, edx, ecx, eax;
  uint32 int_num;
  uint32 err_code;
  uint32 eip, cs, eflags, user_esp, user_ss;
} isr_params_t;

typedef isr_params_t interrupt_stack_t;

这里咱们将它重定义为 interrupt_stack_t 构造,当前以此来示意中断 stack;

留神一下 CPU 主动 push 的 stack 局部,它实际上就曾经解决了上述两个问题:

  • user 代码,保留在了 cseip 中;
  • user stack 的地位,保留在了 user espuser ss 中;

一旦调用 iret,上述保留的数据就会主动 pop 进去并跳转,所以咱们实际上只有在 kernel stack 里构建一个上述的 interrupt_stack_t 构造,并且设置好这些值,通过一次模仿中断返回,使之能帮忙咱们“返回”到 user 态。

按常规,给出本篇的次要源代码,次要是以下几个函数:

  • create_new_user_thread
  • init_thread
  • prepare_user_stack

它们之间是顺次调用的关系,init_thread 函数在启动 kernel thread 那一篇曾经用到过,它既用来初始化 kernel thread,当初也用来进一步初始化 user thread,用一个参数开关 user 管制,因为 user thread 也必须以 kernel thread 为根底演变而来。

筹备 kernel stack

这里先回顾下 kernel 线程中对 kernel stack 的初始化:

留神上方虚线局部的 user interrupt stack,之前在启动 kernel thread 时咱们并没有涉及这一块区域,因为 kernel 线程用不到它,而当初咱们就须要将它填充为下面的 interrupt_stack_t 构造。

来看 init_thread 函数对这一部分的初始化:

interrupt_stack_t* interrupt_stack =
    (interrupt_stack_t*)((uint32)thread->kernel_esp
                         + sizeof(switch_stack_t));
// data segemnts
interrupt_stack->ds = SELECTOR_U_DATA;
// general regs
interrupt_stack->edi = 0;
interrupt_stack->esi = 0;
...
// user-level code env
interrupt_stack->eip = (uint32)function;
interrupt_stack->cs = SELECTOR_U_CODE;
interrupt_stack->eflags =
    EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1;
// user stack
interrupt_stack->user_ss = SELECTOR_U_DATA;

首先正确定位了 interrupt_stack 的地位,即初始 kernel_esp 上方加一个 switch_stack_t 构造大小的地位处;

接下来就是对 interrupt stack 中各个 register 的初始化了:

  • ds 初始化为 user 空间的 data segment
  • 通用寄存器初始化为 0;
  • cs 初始化为 user 空间的 code segment
  • eip 初始化为 user thread 的工作函数,这是创立 thread 时传进来的;
  • eflags 初始化;
  • user_ss 也初始化为 user 空间的 data segment
  • user_esp 没初始化,它去哪里了?!这里临时不初始化,因为 user stack 的地位还待定,它的初始化工作也会放在前面再讲;

启动运行 thread

下面讲过了,user thread 的运行也须要从 kernel thread 开始,所以它们启动的形式是一样的,区别在于 thread_entry_eip 的设置,这里是整个 thread 最初始的入口。

作为比照,如果是运行 kernel 线程,那么 thread_entry_eip 就是被设置为 kernel_thread 工作函数;而这里则被设置为 switch_to_user_mode 函数,来看它的代码,很简略:

switch_to_user_mode:
  add esp, 8
  jmp interrupt_exit

回顾一下 kernel 线程开始运行的过程,在 stack 上从 kernel_esp 地位开始,pop 所有通用寄存器,而后 ret 弹出跳转到 start_eip(即 thread_entry_eip)处,这里即跳转到了 switch_to_user_mode 函数;而后 add esp, 8 跳过两格没用的,使 esp 终于正确地达到了 user interrupt stack 地位:

而后开始执行 interrupt_exit 函数,它是中断处理函数 isr_common_stub 的下半局部,即退出中断并复原中断产生前的 context:

interrupt_exit:
  ; recover the original data segment
  pop eax
  mov ds, ax
  mov es, ax
  mov fs, ax
  mov gs, ax

  popa
  ; clean up the pushed error code and pushed ISR number
  add esp, 8

  ; make sure interrupt is enabled
  sti
  ; pop cs, eip, eflags, user_ss, and user_esp by processor
  iret

联合 user interrupt stack 的结构图,能够看到它开始“复原”(其实不是复原,是咱们初始化结构进去的)user context;它解决了之前提到的两个问题,实质上也是咱们强调过的对于 thread 的两个外围因素:

  • user 代码 (cs + eip)
  • user 栈 (user ss + user esp)

当然 user context 里还包含了 user data segment,通用寄存器,eflags 等寄存器的初始值,这些都一并在这里初始化了。至此,user thread 的运行环境就初始化好了。


这一章节,初看可能会感觉有点乱,你须要好好温习一下对于 segment 的相干内容,以及中断和 kernel 线程初始化和启动的过程。这里实质上要搞清楚 kernel stack 上的两个 stack 的作用:

  • switch stack:这是 kernel 态下代码运行用的 stack,所有对于这个 thread 的 kernel 代码都在这里运行,并且 multi-threads 之间的 context switch 也产生在这里;
  • interrupt stack:这是 user 态进入和退出 kernel 态的中断栈,是产生中断时由 CPU 和中断 handler 独特构建的;

user 线程的初始化运行过程,实质上就是分为了两步:

  • 一开始,和 kernel 线程一样,初始化 kernel thread 运行的环境;
  • 然而到了跳转 start_eip 的中央,本来的 kernel_thread 的运行函数被换成了 switch_to_user_mode 函数,由它开始模仿中断返回的过程,进入 interrupt stack,使之开始安排初始化 user context,并最终跳转到 user 态的(code + stack)开始运行;

筹备 user stack

下面筹备结束了 kernel stack,使之能以中断返回的模式,跳转进入用户 code + stack 上执行。不过咱们的 user stack 还没筹备好,这块区域也须要进行简略的初始化。

首先须要指定 user stack 的地位,一般来说它位于 3GB 空间的顶部区域:

user stack 的地位理当是由这个 thread 的 process 治理的,不过目前咱们并未开始构建 process 相干的内容,这里作为测试,咱们能够临时随便指定 user stack 的地位。在理论的 create_new_user_thread 函数中,会传入参数 process,指定这个 thread 应该创立在哪一个 process 下,而 process 会为这个 user thread 调配一个 user 空间的 stack 地位。

tcb_t* create_new_user_thread(pcb_t* process,
                              char* name,
                              void* user_function,
                              uint32 argc,
                              char** argv);

这样同一个 process 下的多个 threads,它们的 user stack 大抵是这样排布,不能够有重叠:

有了 user stack 的地位当前,咱们就能够初始化这个 stack。user thread 的运行实质上是一次函数调用,所以它的 stack 初始化没有什么非凡之处,就是构建一个函数的调用栈,次要是两个局部:

  • 参数
  • 返回地址

接下来咱们将这两局部内容初始化。

复制参数

参数是在 create_new_user_thread 函数就传入的 argcargv。不过咱们须要将他们 copy 到 user stack 上,这样 user_function 就能够以它们为参数运行起来,这其实就是咱们平时的 C 程序里的 main 函数的模式:

int main(int argc, char** argv);

当然 main 函数只是过程的主 thread,如果在过程中持续创立新的 thread,实质上也是相似的模式,例如罕用的 pthread 库,都会波及到 thread 函数和参数的传递:

int pthread_create(pthread_t *thread,
                   pthread_attr_t *attr,
                   void *(*start_routine)(void *),
                   void *arg); 

复制参数的过程次要在函数 prepare_user_stack 中实现。这里波及到参数 argv 的构建,它是一个字符串数组,所以咱们先把 argv 里所有的字符串都 copy 到 user stack 的顶部,并记下它们的起始地址,使之形成一个 char* 数组,再让 argv 指向这个数组。指针之间的关系略微有点绕,能够联合下图能够看一下:

thread 完结返回

最初还剩下一个 ret addr 没有设置,它是 thread 完结后的返回地址。

这里咱们须要问本人一个问题,一个 thread 完结后,应该做什么?CPU 指令流当然要持续往下走,不能就此终止了,也不能跑飞了。所以 thread 工作函数返回后必然是跳转到某处,由 kernel 对它进行最初的回收工作,这个 thread 的生命周期就算完结了,而后 scheduler 调度运行下一个 thread。

其实这个问题之前也提到过的,在 kernel 线程中,之所以咱们须要用函数 kernel_thread 封装一下,就是因为 thread 须要有一个对立的退出机制:

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

schedule_thread_exit 就是 thread 完结后的退出机制,它不会返回,而是进入 kernel 的完结和回收流程。它的次要工作是将该 thread 的相干资源进行开释,而后标记本人状态为 TASK_DEAD,接下来调用 scheduler 调度服务,scheduler 发现它是 TASK_DEAD 就会将它清理,并调度运行下一个 thread。

所以咱们是不是只有将 user stack 上的 thread 返回地址设置为 schedule_thread_exit 就 OK 了呢?答案是谬误的。

因为 schedule_thread_exit 是 kernel 代码,user 态下是无奈间接调用的,否则会报 segment 谬误。user 空间的 code segment 范畴被限定在了 3GB 以下,并且特权级 CPL 是 3,它不可能调用 3GB 以上,且 DPL 为 0 的 kernel 代码(你可能又须要去温习一下 segment 的内容)。

那么如何能力从 user 态进入 kernel 态并最终调用 schedule_thread_exit 函数呢?答案是中断,或者更精确地说,是 零碎调用 system call)。对于零碎调用的内容,将放在当前的文章中具体开展,在这里只须要晓得,user 态下的 thread 完结形式,应该是运行一个大抵是这样的函数:

void user_thread_exit() {
  // This is a system call.
  thread_exit();}

零碎调用 thread_exit 会率领咱们进入 kernel 态,并最终来到 schedule_thread_exit 函数执行 thread 完结清理工作。

看上去很完满,然而这里又带来一个新的问题,下面的 user_thread_exit 只是咱们假设的,或者说心愿有这么一个函数。实际上 user 的程序代码是用户本人编写的,而后从磁盘上载入运行的,外面是否真的有这么一个函数,是 kernel 不可能通晓的。那咱们实际上陷入了一个悖论,导致 kernel 永远无奈为 user thread 设置一个无效的,user 态下能够调用的 相似 user_thread_exit 的函数,用于作为 user 线程的 ret addr

对于这个问题,我也没有认真钻研过,不晓得规范的解决方案是什么。我的实现办法是,齐全摒弃 user stack 上的 ret addr,也就是说 user thread 的工作函数也永远不返回,而是封装成一个相似 kernel_thread 的函数里,例如叫 user_thread 好了:

void user_thread(thread_func* function,int argc, void** argv) {function(argc, argv);
  thread_exit();}

但实际上咱们无奈强制用户依照这个标准去创立 user 线程,所以 user 态下的线程创立,不应该让用户间接操作底层的零碎调用,而是应该由相应的标准的库函数去封装,例如 pthread,而后用户再去调用这些库函数,进行对 thread 的相干操作。在这些库函数里,它们会将用户传进来的工作函数 function 和参数,都封装到 user_thread 这样的符合规范的 user 线程函数中,而后再调用 OS 提供的,用于创立 thread 的零碎调用。

那至于 main 函数,它也是一个线程,它的退出机制就比拟好解决,也是相似的,必须将 main 也封装在一个函数中:

void _start(int argc, void** argv) {main(argc, argv);
  thread_exit();}

_start 函数就是这个下层的封装函数,实际上它才是真正的用户程序的入口函数,它实践上应该由规范库提供,在 C 程序 link 时,将它链入并设置为 ELF 可执行文件的入口地址。

设置 tss

user 线程的初始化和完结工作都 OK 了,看上去所有准备就绪,不过其实还有一个脱漏的坑没填上。之前讲过 user 态下如果产生中断,CPU 会主动进入 kernel stack 进行中断解决(当然如果 user thread 永远不中断,那就没有这个需要,但这是不可能的。最典型的,时钟中断会继续一直地产生,page fault 也是不可避免的)。

这个从 user 到 kernel 的跳转过程是 CPU 主动实现的,是由硬件决定的。那么问题来了,CPU 怎么晓得这个 thread 的 kernel stack 在哪里呢?

答案是 tss(task state segment),对于这个货色切实是简短繁琐,我也不想在这里赘述。目前只须要晓得将这个构造设置到 gdt 中,并且将线程的 kernel stack 设置到它的 esp0 字段上即可,这样 CPU 每次陷入 kernel 态时,就会找到这个 tss 构造,并且依照 esp0 字段,定位 kernel stack 的地位。

  • tss 初始化相干的代码在 write_tss 这个函数;
  • 记得每次 thread 切换,scheduler 都须要更新 tss 的 esp0 字段的值,使之指向新 thread 的 kernel stack 的顶部(高地址):
void update_tss_esp(uint32 esp) {tss_entry.esp0 = esp;}

void do_context_switch() {
  // ...
  update_tss_esp(next_thread->kernel_stack + KERNEL_STACK_SIZE);
  // ...
}

总结

本篇的内容略微有些简单,也是对于 thread 的最终篇,波及到的内容很宽泛,可能须要温习 segment,中断,以及 kernel 线程创立 + 启动过程等内容,将它们串联起来。趟过了这一关,置信你会对 thread 在 OS 中的运行机制会有一个全面的了解,这包含以下几个关键点:

  • user 和 kernel 线程 / stack 的关系,以及它们各自的作用;
  • user 和 kernel 态的转换是如何产生和返回的,code + stack 如何跳转;
  • kernel stack 在线程启动时,中断产生、解决和返回时,context switch 时的结构图,和它所施展的作用;

下一篇,咱们将在 thread 的根底上,定义过程(process)的概念。

正文完
 0