你管这破玩意儿叫操作系统源码(六)

本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。

第二十一回 | 一个新进程的诞生(一)

一个新进程的诞生(一)先整体看一下 (qq.com)

到了第三部分,简单说就是从内核态切换到用户态,然后通过 fork 创建出一个新的进程,再之后老进程进入死循环。

1
2
3
4
5
6
7
8
9
10
11
12
void main(void) {
// 第二部分的内容,各种初始化工作
...
// 第三部分的内容,一个新进程的诞生
move_to_user_mode();
if (!fork()) {
// 新进程里干了啥,是第四部分的内容
init();
}
// 死循环,操作系统怠速状态
for(;;) pause();
}

第一句是 move_to_user_mode

直译过来即可,就是转变为用户态模式。因为 Linux 将操作系统特权级分为用户态与内核态两种,之前都处于内核态,现在要先转变为用户态,仅此而已。

一旦转变为了用户态,那么之后的代码将一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态。

第二句是 fork

这是创建一个新进程的意思,而且所有用户进程想要创建新的进程,都需要调用这个函数。

原来操作系统只有一个执行流,就是我们一直看过来的所有代码,就是进程 0,只不过我们并没有意识到它也是一个进程。调用完 fork 之后,现在又多了一个进程,叫做进程 1。

当然,更准确的说法是,我们一路看过来的代码能够被我们自信地称作进程 0 的确切时刻,是我们在 第18回 | 进程调度初始化 sched_init 里为当前执行流添加了一个进程管理结构到 task 数组里,同时开启了定时器以及时钟中断的那一个时刻。

因为此时时钟中断到来之后,就可以执行到我们的进程调度程序,进程调度程序才会去这个 task 数组里挑选合适的进程进行切换。所以此时,我们当前执行的代码,才真正有了一个进程的身份,才勉强得到了一个可以被称为进程 0 的资格,毕竟还没有其他进程参与竞争。

第三句是 init

只有进程 1 会走到这个分支来执行。这里的代码可太多了,它本身需要完成如加载根文件系统的任务,同时这个方法将又会创建出一个新的进程 2,在进程 2 里又会加载与用户交互的 shell 程序,此时操作系统就正式成为了用户可用的一个状态了。

第四句是 pause

当没有任何可运行的进程时,操作系统会悬停在这里,达到怠速状态。没啥好说的,我一直强调,操作系统就是由中断驱动的一个死循环。

第二十二回 | 一个新进程的诞生(二) 从内核态到用户态

一个新进程的诞生(二)从内核态到用户态 (qq.com)

1
2
3
4
5
6
7
8
void main(void) {
...
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}

今天我们就重点讲这第一句代码,move_to_user_mode

让进程无法逃出用户态

内核态与用户态的本质-特权级

首先从一个最大的视角来看,这一切都源于 CPU 的保护机制。CPU 为了配合操作系统完成保护机制这一特性,分别设计了分段保护机制分页保护机制

当我们在 第七回 | 六行代码就进入了保护模式 将 cr0 寄存器的 PE 位开启时,就开启了保护模式,也即开启了分段保护机制

当我们在 第九回 | Intel 内存管理两板斧:分段与分页 将 cr0 寄存器的 PG 位开启时,就开启了分页模式,也即开启了分页保护机制

我们目前正在执行的代码地址,是通过 CPU 中的两个寄存器 cs : eip 指向的对吧?cs 寄存器是代码段寄存器,里面存着的是段选择子,还记得它的结构么?

这里面的低端两位,此时表示 CPL,也就是当前所处的特权级,假如我们现在这个时刻,CS 寄存器的后两位为 3,二进制就是 11,就表示是当前处理器处于用户态这个特权级。

假如我们此时要跳转到另一处内存地址执行,在最终的汇编指令层面无非就是 jmp、call 和中断。我们拿 jmp 跳转来举例。

如果是短跳转,也就是直接 jmp xxx,那不涉及到段的变换,也就没有特权级检查这回事。

如果是长跳转,也就是 jmp yyy : xxx,这里的 yyy 就是另一个要跳转到的段的段选择子结构。

这个结构仍然是一样的段选择子结构,只不过这里的低端两位,表示 RPL,也就是请求特权级,表示我想请求的特权级是什么。同时,CPU 会拿这个段选择子去全局描述符表中寻找段描述符,从中找到段基址。

你看,这里面又有个 DPL,这表示目标代码段特权级,也就是即将要跳转过去的那个段的特权级。

这里的检查规则比较多,简单说,绝大多数情况下,要求 CPL 必须等于 DPL,才会跳转成功,否则就会报错。

也就是说,当前代码所处段的特权级,必须要等于要跳转过去的代码所处的段的特权级,那就只能用户态往用户态跳内核态往内核态跳,这样就防止了处于用户态的程序,跳转到内核态的代码段中做坏事。

这只是代码段跳转时所做的特权级检查,还有访问内存数据时也会有数据段的特权级检查,这里就不展开了。最终的效果是,处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段,这也就实现了内存数据读写的保护。

说了这么多,其实就是,代码跳转只能同特权级,数据访问只能高特权级访问低特权级

特权转换的方式

Intel 设计了好多种特权级转换的方式,中断中断返回就是其中的一种。

处于用户态的程序,通过触发中断,可以进入内核态,之后再通过中断返回,又可以恢复为用户态

系统调用就是这么玩的,用户通过 int 0x80 中断指令触发了中断,CPU 切换至内核态,执行中断处理程序,之后中断程序返回,又从内核态切换回用户态。

如果我们当前的代码,此时就是处于内核态,并不是由一个用户态程序通过中断而切换到的内核态,那怎么回到原来的用户态呢?答案还是,通过中断返回。

没有中断也能中断返回?可以的,Intel 设计的 CPU 就是这样不符合人们的直觉,中断和中断返回的确是应该配套使用的,但也可以单独使用,我们看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void main(void) {
...
move_to_user_mode();
...
}

#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \
_asm push eax \
_asm pushfd \
_asm push 0000000fh \
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}

你看,这个方法里直接就执行了中断返回指令 iretd。

那么为什么之前进行了一共五次的压栈操作呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断才行。

怎么假装呢?其实就把栈做做工作就好了,中断发生时,CPU 会自动帮我们做如下的压栈操作。而中断返回时,CPU 又会帮我们把压栈的这些值返序赋值给响应的寄存器。

去掉错误码,刚好是五个参数,所以我们在代码中模仿 CPU 进行了五次压栈操作,这样在执行 iretd 指令时,硬件会按顺序将刚刚压入栈中的数据,分别赋值给 SS、ESP、EFLAGS、CS、EIP 这几个寄存器,这就感觉像是正确返回了一样,让其误以为这是通过中断进来的

CS 和 SS 寄存器是段寄存器的一种,段寄存器里的值是段选择子,其结构上面已经提过两遍了,在 第六回 | 先解决段寄存器的历史包袱问题 中也专门讲了这个结构的作用。

对着这个结构,我们看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \ ; 给 SS 赋值
_asm push eax \
_asm pushfd \
_asm push 0000000fh \ ; 给 CS 赋值
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}

拿 CS 举例,给它赋的值是,0000000fh,用二进制表示为:

0000000000001111

最后两位 11 表示特权级为 3,即用户态。而我们刚刚说了,CS 寄存器里的特权级,表示 CPL,即当前处理器特权级。

所以经过 iretd 返回之后,CS 的值就变成了它,而当前处理器特权级,也就变成了用户态特权级。

刚刚说了 CS 寄存器为 0000000000001111,最后两位表示用户态的含义。

那继续解读,倒数第三位 TI 表示,前面的描述符索引,是从 GDT 还是 LDT 中取,1 表示 LDT,也就是从局部描述符表中取。

第18回 | 大名鼎鼎的进程调度就是从这里开始的 中,将 0 号 LDT 作为当前的 LDT 索引,记录在了 CPU 的 lldt 寄存器中。

1
2
3
4
5
6
7
#define lldt(n) __asm__("lldt %%ax"::"a" (_LDT(n)))

void sched_init(void) {
...
lldt(0);
...
}

而整个 GDT 与 LDT 表的设计,经过整个 第一部分 进入内核前的苦力活第二部分 大战前期的初始化工作 的设计后,成了这个样子。

再看这行代码,把 EIP 寄存器赋值为了那行标号的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void main(void) {
...
move_to_user_mode();
...
}

#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \
_asm push eax \
_asm pushfd \
_asm push 0000000fh \
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}

这里刚好设置的是下面标号 l1 的位置,所以 iretd 之后 CPU 就乖乖去那里执行了。所以其实从效果上看,就是顺序往下执行,只不过利用了 iretd 做了些特权级转换等工作。

第二十三回 | 一个新进程的诞生(三) 如果让你来设计进程调度

一个新进程的诞生(三)如果让你来设计进程调度 (qq.com)

进程调度本质是什么?很简单,假如有三段代码被加载到内存中。

进程调度就是让 CPU 一会去程序 1 的位置处运行一段时间,一会去程序 2 的位置处运行一段时间。

整体流程设计

第一种办法就是,程序 1 的代码里,每隔几行就写一段代码,主动放弃自己的执行权,跳转到程序 2 的地方运行。然后程序 2 也是如此。

第二种办法就是,由一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。

这个每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断

不知道你是否还记得,这个定时器和时钟中断,早在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 里讲的 sched_init 函数里就搞定了。

而那个特殊的程序,就是具体的进程调度函数了。

上下文环境

每个程序最终的本质就是执行指令。这个过程会涉及寄存器内存外设端口

内存还有可能设计成相互错开的,互不干扰,比如进程 1 你就用 0~1K 的内存空间,进程 2 就用 1K~2K 的内存空间,咱谁也别影响谁。

虽然有点浪费空间,而且对程序员十分不友好,但起码还是能实现的。

不过寄存器一共就那么点,肯定做不到互不干扰,可能一个进程就把寄存器全用上了,那其他进程咋整。

所以最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。

Linux 0.11 就是这样做的,每个进程的结构 task_struct 里面,有一个叫 tss 的结构,存储的就是 CPU 这些寄存器的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct task_struct {
...
struct tss_struct tss;
}

struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};

你发现 tss 结构里还有个 cr3 不?它表示 cr3 寄存器里存的值,而 cr3 寄存器是指向页目录表首地址的。

那么指向不同的页目录表,整个页表结构就是完全不同的一套,那么线性地址到物理地址的映射关系就有能力做到不同。

也就是说,在我们刚刚假设的理想情况下,不同程序用不同的内存地址可以做到内存互不干扰。

但是有了这个 cr3 字段,就完全可以无需由各个进程自己保证不和其他进程使用的内存冲突,因为只要建立不同的映射关系即可,由操作系统来建立不同的页目录表并替换 cr3 寄存器即可。

这也可以理解为,保存了内存映射的上下文信息

运行时间信息

所以一个好的办法就是,给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。

在 Linux 0.11 里,这个属性就是 counter

1
2
3
4
5
6
struct task_struct {
...
long counter;
...
struct tss_struct tss;
}

而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。

而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。

1
2
3
4
5
6
7
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}

如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1 操作。

如果已经到 0 了,就触发进程调度,选择下一个进程并使 CPU 跳转到那里运行。

优先级

1
2
3
4
5
6
7
struct task_struct {
...
long counter;
long priority;
...
struct tss_struct tss;
}

每次一个进程初始化时,都把 counter 赋值为这个 priority,而且当 counter 减为 0 时,下一次分配时间片,也赋值为这个。

进程状态

那这个状态可以记录一个属性了,叫 state,记录了此时进程的状态

1
2
3
4
5
6
7
struct task_struct {
long state;
long counter;
long priority;
...
struct tss_struct tss;
}

而这个进程的状态在 Linux 0.11 里有这么五种。

1
2
3
4
5
#define TASK_RUNNING          0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4

好了,目前我们这几个字段,就已经可以完成简单的进程调度任务了。

有表示状态的 state,表示剩余时间片的 counter,表示优先级的 priority,和表示上下文信息的 tss

第二十四回 | 一个新进程的诞生(四) 从一次定时器滴答来看进程调度

一个新进程的诞生(四)从一次定时器滴答来看进程调度 (qq.com)

还记得我们在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 sched_init 的时候,开启了定时器吧?这个定时器每隔一段时间就会向 CPU 发起一个中断信号。

这个间隔时间被设置为 10 ms,也就是 100 Hz。

1
2
3
schedule.c

#define HZ 100

发起的中断叫时钟中断,其中断向量号被设置为了 0x20

还记得我们在 sched_init 里设置的时钟中断和对应的中断处理函数吧?

1
2
schedule.c
set_intr_gate(0x20, &timer_interrupt);

这样,当时钟中断,也就是 0x20 号中断来临时,CPU 会查找中断向量表中 0x20 处的函数地址,即中断处理函数,并跳转过去执行。

这个中断处理函数就是 timer_interrupt,是用汇编语言写的。

1
2
3
4
5
6
7
8
9
10
system_call.s

_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...

这个函数做了两件事,一个是将系统滴答数这个变量 jiffies 加一,一个是调用了另一个函数 do_timer

1
2
3
4
5
6
7
8
9
sched.c

void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}

首先将当先进程的时间片 -1,然后判断:

如果时间片仍然大于零,则什么都不做直接返回。

如果时间片已经为零,则调用 schedule(),很明显,这就是进行进程调度的主干。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void schedule(void) {
int i, next, c;
struct task_struct ** p;
...
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}

不严谨的简化

1
2
3
4
5
void schedule(void) {
int next = get_max_counter_and_runnable_thread();
refresh_all_thread_counter();
switch_to(next);
}

很简答,这个函数就做了三件事:

1. 拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。

2. 如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable 的进程)的 counter 重新赋值(counter = counter/2 + priority),然后再次执行步骤 1。

3. 最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。

看 switch_to 方法,是用内联汇编语句写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sched.h

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,_current\n\t" \
"ljmp %0\n\t" \
"cmpl %%ecx,_last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}

这段话就是进程切换的最最最最底层的代码了。

主要就干了一件事,就是 ljmp 到新进程的 tss 段处。

CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。

上图来源于《Linux内核完全注释V5.0》

这个图在完全注释这本书里里画的非常清晰,我就不重复造轮子了。

简单说就是,保存当前进程上下文,恢复下一个进程的上下文,跳过去


罪魁祸首的,就是那个每 10ms 触发一次的定时器滴答。

而这个滴答将会给 CPU 产生一个时钟中断信号。

而这个中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer。

do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束。

但如果 counter = 0 了,就开始进行进程的调度。

进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里。

switch_to 这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处。

接着,这个进程就舒舒服服地运行了起来,等待着下一次时钟中断的来临。



你管这破玩意儿叫操作系统源码(六)
https://www.spacezxy.top/2023/04/06/OperatingSystem/Operating-system-source-code-6/
作者
Xavier ZXY
发布于
2023年4月6日
许可协议