共计 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 代码,保留在了
cs
和eip
中; - user stack 的地位,保留在了
user esp
和user 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
函数就传入的 argc
和 argv
。不过咱们须要将他们 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
)的概念。