文章系列:
重新认识Intel任务切换(一)
重新认识Intel任务切换(二)
利用堆栈切换实现的进程切换
堆栈实现进程切换原理
- 一句话解释,TSS硬件机制实现任务切换的本质是为CPU的执行提供堆栈和指令地址,堆栈记录函数的调用和返回,指令地址实现代码的转移。只要把CPU的堆栈和指令换了,就能达到切换进程的目的,就是更改SS、ESP、CS、EIP的内容。
- 寄存器内容更改原理
- SS、ESP两个寄存器通过普通的mov指令可以更新内容
- CS、EIP两个寄存器通过RETF或RET指令更新内容
- RETF 第一步将栈当前值POP出来给EIP,第二步移动指针将下一个值POP出来给CS
- RET 将栈当前值POP出来给EIP
- 核心流程
- 当前SS、ESP、CS和EIP寄存器内容通过mov指令保存到内存,将下一个进程的SS、ESP从内存mov到SS和ESP寄存器。CS和EIP不能直接mov。
- 将下一个进程的CS和EIP push到堆栈,使用RETF指令加载CS和EIP。或者,将EIP push到堆栈,使用RET指令加载EIP。
Linux进程切换关键代码分析
各个CPU架构的进程切换实现不同,本文只关注Intel架构。kernel version: 2.6.30
1) 函数调用链
kernel/sched.c
schedule
__schedule
context_switch(rq, prev, next)
switch_to(prev, next, prev)
arch/x86/include/asm/system.h
2)关键代码
define switch_to(prev, next, last)
do {
/*
* Context-switching clobbers all registers, so we clobber
* them explicitly, via unused output variables.
* (EAX and EBP is not listed because EBP is saved/restored
* explicitly for wchan access and EAX is the return value of
* __switch_to())
*/
unsigned long ebx, ecx, edx, esi, edi;
asm volatile("pushfl\n\t" /* save flags */
"pushl %%ebp\n\t" /* save EBP */
"movl %%esp,%[prev_sp]\n\t" /* save ESP */
保存ESP到内存(当前进程的thread_struct结构)
"movl %[next_sp],%%esp\n\t" /* restore ESP */
从内存(下个进程的thread_struct结构)中取出ESP加载到寄存器
"movl $1f,%[prev_ip]\n\t" /* save EIP */
保存EIP到内存((当前进程的thread_struct结构)),EIP的值就是标号1处的偏移地址
"pushl %[next_ip]\n\t" /* restore EIP */
从内存((下个进程的thread_struct结构))中取出EIP,PUSH到当前堆栈,RET调用时就能加载到EIP
__switch_canary
"jmp __switch_to\n" /* regparm call */
__switch_to函数返回时调用RET指令,将CPU的EIP改变,跳转到下一个任务的标号1:处继续执行
"1:\t"
"popl %%ebp\n\t" /* restore EBP */
"popfl\n" /* restore flags */
/* output parameters */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip),
"=a" (last),
/* clobbered output registers: */
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
__switch_canary_oparam
/* input parameters: */
: [next_sp] "m" (next->thread.sp),
[next_ip] "m" (next->thread.ip),
/* regparm parameters for __switch_to(): */
[prev] "a" (prev),
[next] "d" (next)
__switch_canary_iparam
: /* reloaded segment registers */
"memory");
} while (0)
实验
- 目标
实现两个任务来回切换,分别在屏幕上打印’H’,'Y’字符 - 任务切换实现
Linux的实现中,没有更新CS和SS代码段,下面的实验中会更新(虽然CS的内容一样)。
另外,由于没有实现存放CS和EIP的内存数据结构,实验中要切换任务的CS和EIP都是常量,直接使用,不从内存中去。
1)准备任务代码,两个代码段,分别打印两个字符
LABEL_SEG_TASK0:
mov ax, SelectorVideo
mov gs, ax
mov edi,(80 * 11 + 79) * 2
mov ah, 0Ch
mov al, 'H'
mov [gs:edi], ax
mov ecx, 0ffffffh
.0:
dec ecx
jecxz .1
jmp .0
.1:
mov ebx, 1
;ebx作为一个参数指定要跳转的任务
;1:跳转到TASK1
;0:跳转到TASK0
jmp SelectorInitCode:OffSwitchTask ;切换到任务1
jmp SelectorTaskCode0:0 ;执行完任务1后继续从头执行任务0
Task0CodeLen equ $ - LABEL_SEG_TASK0
LABEL_SEG_TASK1:
mov ax, SelectorVideo
mov gs, ax
mov edi,(80 * 11 + 79) * 2
mov ah, 0Ch
mov al, 'Y'
mov [gs:edi], ax
mov ecx, 0ffffffh
.2:
dec ecx
jecxz .3
jmp .2
.3:
mov ebx, 0
jmp SelectorInitCode:OffSwitchTask ;切换到任务0
jmp SelectorTaskCode1:0 ;执行完任务0后继续从头执行任务1
Task1CodeLen equ $ - LABEL_SEG_TASK1
2)任务切换函数
LABEL_SEG_INIT:
xor eax, eax
mov ax, SelectorStack0 ;初始化TASK0的堆栈,往里面PUSH CS和EIP,预先存放好要执行的代码
mov ss, ax
xor eax, eax
mov ax, TopOfStack0
mov sp, ax
push SelectorTaskCode0 ;PUSH CS到堆栈,Linux没有这一步;使用常量,没有从内存数据结构中取
push 0 ;PUSH EIP到堆栈,相当于Linux中的pushl %[next_ip];使用常量,没有从内存数据结构中取
xor eax, eax
mov ax, SelectorStack1 ;初始化TASK1的堆栈,往里面PUSH CS和EIP,预先存放好要执行的代码
mov ss, ax
xor eax, eax
mov ax, TopOfStack1
mov sp, ax
push SelectorTaskCode1
push 0
mov ebx, 1 ;跳转到任务1
jmp SwitchTask
SwitchTask:
OffSwitchTask equ SwitchTask - $$
cmp ebx, 1 ;判断是要切换到任务1还是任务0
je .4
; switch to stack0
xor eax, eax
mov ax, SelectorStack0 将任务0的堆栈加载到SS,里面依次存放任务0代码段的EIP和CS
mov ss, ax
mov ax, TopOfStack0 - 8
mov sp, ax ;切换堆栈
retf ;RETF依次POP EIP和CS,加载到CS和EIP寄存器,跳转到任务0
.4:
; switch to stack1
xor eax, eax
mov ax, SelectorStack1 ;将任务1的堆栈加载到SS,里面依次存放任务1代码段的EIP和CS
mov ss, ax
mov ax, TopOfStack0 - 8
mov sp, ax ;切换堆栈
retf ;RETF依次POP EIP和CS,加载到CS和EIP寄存器,跳转到任务1
3)声明代码段和数据段,生成选择子
[SECTION .gdt]
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_INITCODE: Descriptor 0, InitCodeLen - 1, DA_C + DA_32; 非一致代码段
LABEL_DESC_TASKCODE0: Descriptor 0, Task0CodeLen - 1, DA_C + DA_32 ; 非一致代码段
LABEL_DESC_TASKCODE1: Descriptor 0, Task1CodeLen - 1, DA_C + DA_32 ; 非一致代码段
LABEL_DESC_STACK0: Descriptor 0, TopOfStack0, DA_DRW; 堆栈段
LABEL_DESC_STACK1: Descriptor 0, TopOfStack1, DA_DRW; 堆栈段
LABEL_DESC_TSS0: Descriptor 0, TSS0Len -1, DA_386TSS;
LABEL_DESC_TSS1: Descriptor 0, TSS1Len -1, DA_386TSS;
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW; 显存首地址
; GDT 结束
GdtLen equ $ - LABEL_GDT; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
dd 0 ; GDT基地址
; GDT 选择子
SelectorInitCode equ (LABEL_DESC_INITCODE - LABEL_GDT)
SelectorTaskCode0 equ (LABEL_DESC_TASKCODE0 - LABEL_GDT)
SelectorTaskCode1 equ (LABEL_DESC_TASKCODE1 - LABEL_GDT)
SelectorStack0 equ (LABEL_DESC_STACK0 - LABEL_GDT)
SelectorStack1 equ (LABEL_DESC_STACK1 - LABEL_GDT)
SelectorTSS0 equ (LABEL_DESC_TSS0 - LABEL_GDT)
SelectorTSS1 equ (LABEL_DESC_TSS1 - LABEL_GDT)
SelectorVideo equ (LABEL_DESC_VIDEO - LABEL_GDT)
; END of [SECTION .gdt]
- 结果分析
- SelectorInitCode中执行指令
mov ebx, 1; jmp SwitchTask
前,TASK0和TASK1的代码段分别对应选择子0x10和0x18,堆栈段分别对应0x20和0x28,打印出堆栈的内容,可以看到堆栈中已经存放了两个任务的代码段选择子0x10和0x18,由与EIP写入的是0,所以区别不出来。当前堆栈SS=0x28是任务1的堆栈。
- 从SwitchTask返回时调用
retf
,ebx传入的1表示切换到任务1。当前堆栈本来就使用的任务1的堆栈,因此SS没有变化,RETF执行后会跳转到任务1SelectorTaskCode1:0
指向的地方。RETF执行后CS和IP的值都更新成TASK1堆栈中的值。
- 从任务1切换到任务0代码如下
mov ebx, 0
jmp SelectorInitCode:OffSwitchTask
.4:
; switch to stack1
xor eax, eax
mov ax, SelectorStack1 ;将任务1的堆栈加载到SS,里面依次存放任务1代码段的EIP和CS
mov ss, ax ;
mov ax, TopOfStack0 - 8
mov sp, ax ;切换堆栈
retf
jmp SelectorTaskCode1:0
执行retf
前,可以看到SS时0x20,是TASK0的堆栈,里面存放的时TASK0的CS和EIP。RETF执行后,CS和EIP将更新成TASK0堆栈的值。
Linux进程调度总结
- Linux进程调度的原理是切换堆栈后使用ret指令,将EIP寄存器加载成下一个任务要执行的IP
- Linux进程调度没有使用TSS的机制,不能使用TSS现成的数据结构,因此需要申请一段内存保存各个任务的硬件上下文,thread_struct结构体的作用就是这个
- Linux任务切换所有动作都在内核空间完成,不涉及特权级改变时的堆栈切换
附: 实验完整源码见my github Linux任务切换