线程切换

进程:指令执行序列+资源。进程的切换除了PC指针(程序计数器,存放下一条指令地址)的变化,映射表(资源)也要发生变化。

线程:一个进程中有多个线程,它们共用该进程中的资源。所以线程的切换不需要对资源进行切换。线程的切换的速度就要比进程的切换要快很多。

先说线程的切换

线程A在切换到线程C的时候需要保存线程A之前运行的结果和之后又要重那里接着运行的地址。

这里我们使用栈来保存

Untitled

上图是两个线程使用一个栈,其中数字对应着地址。Yeild函数指定切换到那里。在用户态可以自己编写切换程序,但是在内核中只能使用内核的切换算法了。

想一想这样做能成功的从2准确的回到3并正常运行?

当回到(3)处,遇到“}”时要出栈。出栈的第一个地址为404,现在有回到另一个线程。然后执行404后的代码再遇到”}”时有出栈,地址为304。这就切换失败了,但是能切换。

如果每个线程都有自己的栈会不会更好一点。这就出现每个线程都有自己的线程控制块(TCB)。每个栈都与TCB进行关联。当前栈的栈顶地址是存放在CPU里面的esp寄存器里面的, 因此只需要改变esp的值就可以切换栈了

1
2
3
4
5
6
void Yield()
{
TCB2.esp = esp; // 保存当前栈顶地址
esp = TCB1.esp; // 切换栈
jmp 204;
}

现在(3)后遇到“}”后根据栈出了的地址为204。我们想要的是回到104地址了,还是失败了,但是并没有在另一个线程中了。

解决:

Untitled

就是把Yield中的 jmp 204 地址去到。也就是在Yield中的函数中“}”时就把204地址弹出了,再遇到B()中的”}”时弹出来的就是104地址了。

现在就线程切换的思路了。

目前只是用户态下的切换,并没有涉及到内核态。

问题:用户态线程内核是不知道的,当用户态中的一个线程要去访问磁盘资源,内核不知道,就让该进程继续使用CPU资源,但是它并没有在使用CPU。这就造成了资源的浪费。

用户态一个进程有多个线程,当一个线程阻塞了。内核是看不到用户的线程,则就把进程切换了。导致进程中的线程无法切换了。

Untitled

多核处理器用的是同一个MMU(Memory Management Unit),这就是多线程模式。则内核级线程技术,能更好的发挥硬件的优势。

Untitled

用户级线程与内核级线程:

用户级线程在发生中断才能进入到内核级线程

内核级线程是要在内核中执行的程序。则内核级线程需要在用户态和核心态里跑,用户态有一个用户栈,核心态中有一个核心栈。用户栈和核心栈称为一套栈。


同又出现了一个新的问题就是:用户态的线程怎么和内核态下的线程协同合作呢?

用户栈与内核栈之间的关联。

一个栈到一套栈;两个栈到两套栈。用户态下的栈与内核态下的栈关联起来。

中断时,线程进入到内核中,OS根据硬件的寄存器来知道这个线程,它对应的内核栈在那里。同时回将用户态的栈的位置(SS,SP)和程序执行到那里的位置(CS(Code Segment),IP(Instruction Pointer))都压入内核栈中保存下来。

线程在内核中执行完,退出内核态回到用户态时(也就是iret指令),就根据之前的内核栈中存入的SS和SP的值来找用户栈,根据CS和PC值找程序接下来该执行的位置。

例子:

Untitled

在内核中线程的切换是通过switch_to()函数实现的,就是从当前线程的内核栈切换到next线程的内核栈。

内核级线程的代码还是在用户态的,只是进入内核态完成系统调用,就是逛一圈之后还是要回去执行的。

线程S切换到线程T要根据线程T的内核栈找到这个线程阻塞的位置,执行线程T的函数。线程T的内核栈之后,通过iret指令从内核栈返回到用户态执行线程T的代码。

内核级线程的切换五个步骤:

  • 中断进入内核
  • 在内核态中,由于启动磁盘或者时钟中断,引发线程切换
  • 通过TCB对内核栈进行切换
  • 使用iret推出中断,对用户栈进行切换。内核栈和用户栈都完成了切换
  • 如果线程不是同一个进程,还需要对内存映射表进行切换

实验:基于内核栈切换的进程切换

环境:linux0.11

Linux0.11完成进程切换是依靠任务状态段(task state segment, TSS)的切换来完成的。

现在我们要舍弃TSS,重新写一个切换程序。

关于TSS具体的过程参考该链接

有一个任务寄存器(Task Register,简称 TR)指向当前进程对应的 TSS 结构体,所谓的 TSS 切换就将 CPU 中几乎所有的寄存器都复制到 TR 指向的那个 TSS 结构体中保存起来,同时找到一个目标 TSS,即要切换到的下一个进程对应的 TSS,将其中存放的寄存器映像“扣在” CPU 上,就完成了执行现场的切换。

虽然用一条指令就能完成任务切换,但这指令的执行时间却很长,这条 ljmp 指令在实现任务切换时大概需要 200 多个时钟周期(TSS)。而通过堆栈实现任务切换可能要更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得 CPU 的设计变得简单

进程的切换:PCB和内存映射表都要切换

原始的切换位置的程序

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*
* 'schedule()'是调度函数。这是个很好的代码!没有任何理由对它进行修改,因为它可以在所有的
* 环境下工作(比如能够对IO-边界处理很好的响应等)。只有一件事值得留意,那就是这里的信号
* 处理代码。
* 注意!!任务0 是个闲置('idle')任务,只有当没有其它任务可以运行时才调用它。它不能被杀
* 死,也不能睡眠。任务0 中的状态信息'state'是从来不用的。
*/
void schedule (void)
{
int i, next, c;
struct task_struct **p; // 任务结构指针的指针。

/* 检测alarm(进程的报警定时值),唤醒任何已得到信号的可中断任务 */

// 从任务数组中最后一个任务开始检测alarm。
for (p = &LAST_TASK; p > &FIRST_TASK; --p)
if (*p)
{
// 如果任务的alarm 时间已经过期(alarm<jiffies),则在信号位图中置SIGALRM 信号,然后清alarm。
// jiffies 是系统从开机开始算起的滴答数(10ms/滴答)。定义在sched.h 第139 行。
if ((*p)->alarm && (*p)->alarm < jiffies)
{
(*p)->signal |= (1 << (SIGALRM - 1));
(*p)->alarm = 0;
}
// 如果信号位图中除被阻塞的信号外还有其它信号,并且任务处于可中断状态,则置任务为就绪状态。
// 其中'~(_BLOCKABLE & (*p)->blocked)'用于忽略被阻塞的信号,但SIGKILL 和SIGSTOP 不能被阻塞。
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state == TASK_INTERRUPTIBLE)
(*p)->state = TASK_RUNNING; //置为就绪(可执行)状态。
}

/* 这里是调度程序的主要部分 */

while (1)
{
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
// 这段代码也是从任务数组的最后一个任务开始循环处理,并跳过不含任务的数组槽。比较每个就绪
// 状态任务的counter(任务运行时间的递减滴答计数)值,哪一个值大,运行时间还不长,next 就
// 指向哪个的任务号。
while (--i)
{
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
// 如果比较得出有counter 值大于0 的结果,则退出124 行开始的循环,执行任务切换(141 行)。
if (c)
break;
// 否则就根据每个任务的优先权值,更新每一个任务的counter 值,然后回到125 行重新比较。
// counter 值的计算方式为counter = counter /2 + priority。[右边counter=0??]
for (p = &LAST_TASK; p > &FIRST_TASK; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
}
switch_to (next); // 切换到任务号为next 的任务,并运行之。
}

switch_to()为切换函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//TSS切换函数如下
#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]));
}

修改:我们需要一个指针指向我们要切换的地址,进程切换必然要涉及到 LDT 的切换。linux0.11是没有线程的。

每个进程对应有 1 个 TSS 和 1 个 LDT,LDT 就是个映射表

1
2
3
4
5
6
7
8
9
//对应的修改为:
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
{
/* code */
c = (*p)->counter, next = i; pnext = *p;
}
//当前的进程使用全局变量保存的,所以只需要下一个要切换的进程
//pnext 是任务结构指针,也就是堆栈
switch_to(pnext, _LDT(next));

重新switch_to函数

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
.align 2 #对齐伪指令,必须从一个能被2整除的地址开始为下面的内存变量分配空间
switch_to:
#这一部分的解释,看上述内存模型: stack
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx # 调用switch_to的第一个参数,即pnext——目标进程的PCB
cmpl %ebx, current #和 current 做一个比较, current指向当前进程的PCB
je 1f #相等就啥也不用做,跳转到“1:”直接恢复保存的寄存器即可
#不相等,开始进程切换
#先切换PCB
movl %ebx,%eax #eax指向目标进程的PCB
xchgl %eax,current #current指向目标进程的PCB, eax指向当前进程的PCB

#TSS中的内核指针的重写
movl tss,%ecx #ecx指向当前进程的TSS
addl $4096,%ebx #ebx指向的是目标进程的PCB,+4096后指向目标进程的内核栈
movl %ebx,ESP0(%ecx) #TSS的esp0指向目标进程的内核栈

#切换内核栈栈顶指针(切换当前的内核栈为目标内核栈);当然要先保存被切换进程的esp
movl %esp, KERNEL_STACK(%eax) #把当前进程的esp保存到其PCB中
movl 8(%ebp),%ebx #再一次把目标进程的PCB存储到ebx中
movl KERNEL_STACK(%ebx),%esp #把目标进程PCB中的内核栈基址存储到esp中

#切换LDT
movl 12(%ebp),%ecx #把_LDT(next)存储到ecx中
lldt %cx #修改LDTR寄存器后,目标进程在执行用户态程序时使用的映射表就是自己的LDT表,实现了地址空间的分离。

#通过ret完成PC的切换

#切换LDT之后,更新fs,指向目标进程的用户态内存
movl $0x17,%ecx #用户空间数据段选择符为0x17
mov %cx,%fs

#和后面的clts配合来处理协处理器,由于和主题关系不大,此处不做论述
cmpl %eax, last_task_used_math
jne 1f
clts
1:
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret

由于现在的 Linux 0.11 的 PCB 定义中没有保存内核栈指针这个域,所以需要加上,而宏 KERNEL_STACK 就是你加的那个位置,当然将 kernelstack 域加在 task_struct。

在linux-0.11/include/linux/sched.h

1
2
3
4
5
6
7
8
9
10
11
12
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long kernelstack;
long signal;
struct sigaction sigaction[32];
.
.
.
}

修改了结构体,同时也要修改该结构体的初始化。位置要对应起来

1
2
3
4
5
6
7
8
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task, \
/* signals */ 0,{{},},0, \
/* ec,brk... */ 0,0,0,0,0,0, \
/* pid etc.. */ 0,-1,0,0,0, \
/* uid etc */ 0,0,0,0,0,0, \
/* alarm */ 0,0,0,0,0,0, \
/* math */ 0, \

修改fork

就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESPCS:IP 关联在一起。

在 fork() 的核心实现 copy_process 中,p = (struct task_struct *) get_free_page();用来完成申请一页内存(大小4k)作为子进程的 PCB

而 p 指针加上页面大小就是子进程的内核栈位置,所以语句 krnstack = (long *) (PAGE_SIZE + (long) p); 就可以找到子进程的内核栈位置

接下来就是初始化krnstack

1
2
3
4
5
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;

完成了如下图的关联

Untitled

由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,现在虽然是使用内核栈完成任务切换,但 fork() 的基本含义不会发生变化。

内核切换完事,到子进程的内核栈开始工作,接下来做的四次弹栈以及 ret 处理使用的都是子进程内核栈中的东西,

1
2
3
4
5
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret

Untitled

为了顺利的完成这些弹栈工作,子进程的内核栈中应该有这些内容,所以需要对 krnstack 进行初始化

1
2
3
4
5
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
// 这里的 0 最有意思。
*(--krnstack) = 0;//eax

现在到了 ret 指令了,这条指令要从内核栈中弹出一个 32 位数作为 EIP 跳去执行,所以需要弄一个函数地址(仍然是一段汇编程序,所以这个地址是这段汇编程序开始处的标号)并将其初始化到栈中

我们弄的一个名为 first_return_from_kernel 的汇编标号,然后可以用语句 (--krnstack) = (long) first_return_from_kernel; 将这个地址初始化到子进程的内核栈中,现在执行 ret 以后就会跳转到 first_return_from_kernel 去执行了。

first_return_from_kernel 完成用户栈和用户代码的切换。依靠的核心指令就是 iret,切换之前需要回复一下执行现场

first_return_from_kernel 的核心代码

1
2
3
4
5
6
7
8
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret

将存放在 PCB 中的内核栈指针修改到初始化完成时内核栈的栈顶

1
p->kernelstack = krnstack;

问题

1
2
3
4
5
6
7
8
9
10
11
//kernel/sched.c在schedule函数中
//下面这段代码能正常编译,但是无法正常运行
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i; pnext = *p;

//而这段代码这可以正常运行
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i, pnext=*p; /**/
//其实这两段代码都是相同,为什么有这种差异。
//我重新写一遍又都正常了,奇怪。
//就这个问题困扰了我很久还没有解决,只有重新写。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!