linux内核分析学习笔记:操作系统的进程切换
来源:互联网 发布:php curl 设置头部 编辑:程序博客网 时间:2024/05/16 14:52
原创作品(陈晓爽 cxsmarkchan)
转载请注明出处
《Linux内核分析》MOOC课程学习笔记
操作系统中有大量进程在运行,而在单核CPU中,每个时刻只能有一个进程的指令在被执行。因此,操作系统需要不断进行进程切换,即分时工作。问题是:如果一个进程在执行过程中被中断,如何记录其中断位置?在下一次执行的时候,如何保证该进程的数据没有被破坏?这些都是进程切换时需要做的工作。本文从一个简单的时间片轮转多道程序内核代码为例,说明操作系统进程切换的原理。
本文为linux内核分析课程的学习笔记,实验在实验楼 Linux内核分析平台上完成。
本文分析的源代码来自https://github.com/mengning/mykernel/。
1 进程切换的原理
1.1 进程现场信息
1.1.1 数据结构
每个进程均包含代码段和堆栈段,代码段和堆栈段均存储在内存中。在进程切换时,要完成两方面工作:
1. 切换程序当前执行位置(即eip指针);
2. 切换程序的堆栈指针(即ebp指针和esp指针,其中ebp为堆栈底端,esp为堆栈顶端)。
因此,操作系统可以维护以下数据结构,以存储程序堆栈信息:
struct Thread{ unsigned long ip; //程序当前执行位置 unsigned long sp; //程序的堆栈顶端指针};
需要注意的是,堆栈的底端指针ebp在进程切换中不需要存储到该数据结构中,原因可见1.1.2节。
1.1.2 保存和恢复eip的方法
保存和恢复eip指针时,需要注意,eip指针不能通过汇编语言直接改变,只能通过call/ret语句间接访问。
有两种方案实现eip指针的保存:
1. 调用call f
语句实现eip的压栈,在f:
中执行popl ip
将eip保存到ip中。
2. 把一个固定的位置作为返回位置,存入ip中,例如movl $1f, ip
。该方法可用于每次返回都要执行固定代码的情况,本文即将分析的代码即属于该情况。
eip的恢复相对比较简单,只需将ip值压栈,然后调用ret
,即可弹栈并跳转到指定的位置。
1.1.3保存和恢复堆栈信息的方法
初看上去,堆栈的顶端(esp)和底端(ebp)都需要存储,但实际上,操作系统只需维护esp的信息。这是因为:
1. 在切换进程前执行pushl %ebp
将堆栈底端指针压栈,此时esp指向的内存地址即存储ebp的值。然后再将esp存储到Thread对象中,即可完成堆栈信息的保存。
2. 在切换回该进程的时候,只需恢复esp,然后执行popl %ebp
弹栈,即可恢复ebp。
1.2 进程控制块(Process Control Block, PCB)
进程控制块是操作系统维护进程的单元。一个进程控制块可为如下结构:
struct PCB{ unsigned long pid; //进程id volatile long state; //进程状态,-1表示未执行,0表示正在执行 char stack[STACK_SIZE]; //进程的堆栈空间,STACK_SIZE为最大的堆栈空间 struct Thread thread; //进程的现场信息 unsigned long task_entry; //进程的初始入口 struct PCB *next; //下一个进程(可构成进程链表。链表并非典型的操作系统进程管理方式,只是本博客采用该方法。)};
其中, 除了thread为进程现场信息,需要在进程切换中使用以外,其余信息均为进程初始化的时候使用。
2 一个简单的实验
2.1 实验设置
打开实验楼 Linux内核分析平台,选择第2个实验:完成一个简单的时间片轮转多道程序内核代码。在该实验平台上,我们采用qemu模拟一个操作系统。
进入实验后,在控制台输入如下命令:
cd LinuxKernel/linux-3.9.4qemu -kernel arch/x86/boot/bzImage
即可得到如下的输出结果:
可以看出,该结果为两行程序的交替执行。进一步地,执行cd mykernel
进入LinuxKernel/linux-3.9.4/mykernel
文件夹,可以看到两个文件mymain.c
和myinterrupt.c
,在mymain.c
可以看到my_start_kernel
函数:
在myinterrupt.c
中可以看到my_timer_handler
函数:
将两段函数的代码和程序执行结果联系起来,我们已经可以得出如下结论:
1. my_start_kernel函数是一个无限循环,在程序运行后就一直在执行;
2. my_timer_handler是一个输出语句,但在执行结果中不断重复出现,可见该函数是在定时执行。
3. 因此,操作系统以my_start_kernel作为入口,同时含有一个定时中断,每隔一段时间就执行一次my_timer_handler函数,然后返回my_start_kernel
这里,对于操作系统的加载过程不做深究,仅通过改写这两个函数,实现操作系统的进程切换。
2.2 实验过程
用https://github.com/mengning/mykernel下的mymain.c和myinterrupt.c替换mykernel文件夹下的相应文件,并加入mypcb.h。再次切换到LinuxKernel/linux-3.9.4
文件夹下运行qemu,可以得到如下运行结果:
截图展示了部分运行结果。实际上,系统共维护4个进程,标号分别为0,1,2,3,每当my_timer_handler调用一次,就会进行一次进程切换。我们比较关系的,是进程切换中现场的保存和恢复,这些内容将在1.3节中详细说明。
3 源码分析
源码中,mypcb.h
用于定义进程控制块,和1.2节内容基本相同,此处略去,重点分析mymain.c
和myinterrupt.c
中的相关内容。
3.1 全局变量声明
变量声明在mymain.c
中,如下:
tPCB task[MAX_TASK_NUM]; //各个进程的进程控制块,MAX_TASK_NUM在mypcb.h中定义为4tPCB *my_current_task = NULL;//当前进程控制块的指针volatile int my_need_schedule = 0;//是否需要进行进程调度
3.2 初始化内容
在my_start_kernel
中进行进程的初始化。初始化代码及其注解如下:
void __init my_start_kernel(void){ int pid = 0; int i; //第一个进程的PCB初始化 task[pid].pid = pid; task[pid].state = 0; //首个执行的进程,此处默认其已经执行 task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process;//进程入口为my_process task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE - 1]; //进程初始堆栈位置,初始在堆栈底端(即堆栈最高位) task[pid].next = &task[pid];//构造循环链表,以便顺序执行 //其他进程PCB的初始化 for(i = 1; i < MAX_TASK_NUM; i++){ memcpy(&task[i], &task[0], sizeof(tPCB)); //复制第一个进程,再稍作修改 task[i].pid = i; task[i].state = -1;//尚未执行的进程,其state默认为-1 task[i].thread.sp = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE - 1];//在这个简单的程序中,4个进程入口均为my_process,通过my_current_task得知当前进程。在真正的操作系统中应该不会这样。 task[i].next = task[i - 1].next; task[i - 1].next = &task[i];//这两句是在循环链表中插入元素 } pid = 0; my_current_task = &task[pid]; //当前进程 //这段汇编代码用于装载第一个进程,这段代码按理说可以和进程切换部分的汇编代码相同(除去装载第一个进程时不需要保存现场以外),但很奇怪的是,源代码中这两段代码并不相同,这一点我也不太清楚原因。 asm volatile( "movl %1, %%esp \n\t" //装载esp "pushl %1 \n\t" //ebp(此时等于esp)压栈,为之后弹栈ebp做准备 "pushl %0 \n\t" //ip压栈 "ret \n\t" //跳转到给定的ip执行,这里是my_process的入口程序 "popl %%ebp\n\t" //该代码不会立即执行,具体执行时间也不太清楚。我总觉得这是一句错误代码Orz...anyway,并不会影响程序运行,因为堆栈操作都是走栈顶。 : : "c"(task[pid].thread.ip), "d" (task[pid].thread.sp) );}
3.3 进程执行部分和定时中断部分
进程执行部分my_process
是4个进程共同的入口,代码如下:
void my_process(void){ int i = 0; while(1){ i++; if(i%100000000 == 0){ printk(KERN_NOTICE "this is process %d - \n", my_current_task->pid); if(my_need_schedule == 1){ my_need_schedule = 0; my_schedule();//在my_need_schedule为1的时候,调用进程切换函数。 } printk(KERN_NOTICE "this is process %d + \n", my_current_task->pid); } }}
可见该函数是在一定次数的循环之后输出一条信息,并且检查my_need_schedule,如果为1,则将其置0,并调用进程切换函数切换进程。
my_need_schedule的置1则出现在定时中断中:
void my_timer_handler(void){ if(time_count %1000 == 0 && my_need_schedule != 1){ printk(KERN_NOTICE ">>>my_timer_handler here<<<\n"); my_need_schedule = 1;//在一定时间后,将my_need_schedule置1,这样,my_process继续执行的时候,就会调用my_schedule进行进程切换。 } time_count++;}
3.4 进程切换部分
进程切换部分my_schedule
是本程序的关键,此处进行详细分析:
3.4.1 源代码
my_schedule
源代码如下:
void my_schedule(void){ //这部分很简单,不再解释 tPCB *next, *prev; if(my_current_task == NULL || my_current_task->next == NULL){ return; } printk(KERN_NOTICE ">>>my_schedule<<<\n"); next = my_current_task->next;//即将切换到的进程 prev = my_current_task;//当前进程 //state=-1表示第一次执行,state=0表示之前执行过被中断,现在继续执行 if(next->state == 0){ //以下为state==0时的代码,详见下文 }else{ //以下为state==-1时的代码,详见下文 //state==0或-1的区别只在恢复现场部分:state==0时,程序已经运行,可能有堆栈操作,因此需恢复ebp信息;state==-1时,程序第一次执行,ebp和esp相等。 }}
以上是代码的框架,state==0时代码如下:
//对于继续执行的代码,需要恢复堆栈信息,即popl %ebpasm volatile( //以下是跳转之前的操作 "pushl %%ebp\n\t" //保存现场:ebp压栈 "movl %%esp, %0 \n\t" //保存现场:esp存入PCB中 "movl %2, %%esp\n\t" //恢复现场:新进程的esp "movl $1f, %1\n\t" //保存现场:这里强制将下一条指令放在1:处($1f在汇编时会指向1:的位置) "pushl %3 \n\t" //恢复现场:新进程的eip "ret \n\t" //跳转执行,以上为前一个进程的操作 //以下是跳转之后的操作 "1:\t" //这句代码标志了汇编代码中“1:”的位置 "popl %%ebp \n\t" //恢复执行后首先弹栈,得到ebp :"=m" (prev->thread.sp), "=m" (prev->thread.ip) :"m" (next->thread.sp), "m"(next->thread.ip) );//恢复执行后,执行以下两行语句,然后函数返回。my_current_task = next;printk(KERN_NOTICE ">>>switch %d to %d<<<\n", prev->pid, next->pid);
state==-1
时代码如下:
next->state = 0;my_current_task = next;printk(KERN_NOTICE ">>>switch %d to %d<<<\n", prev->pid, next->pid);asm volatile( "pushl %%ebp \n\t" //保存现场:ebp压栈 "movl %%esp, %0 \n\t" //保存现场:esp "movl %2, %%esp \n\t" //初始化esp "movl %2, %%ebp \n\t" //初始化ebp "movl $1f, %1 \n\t" //保存现场:eip,这里的$1f仍指向1:处,即state==0中代码定义的位置 "pushl %3 \n\t" //存入程序入口 "ret \n\t" //跳转 :"=m" (prev->thread.sp), "=m"(prev->thread.ip) :"m" (next->thread.sp), "m"(next->thread.ip) );
3.4.2 保存现场
3.4.1中已经进行了注解,保存现场一共有如下内容:
1. ebp压栈
2. esp存入PCB中
3. 将当前位置存入PCB中,本例简单地存入1:
位置,恢复执行的时候都会从1:
位置开始执行。
3.4.3 恢复现场
在state==-1
时,恢复现场即初始化,比较简单,有如下操作:
1. 置入esp和ebp,两者相等,表示堆栈中无内容;
2. 程序入口ip压栈;
3. 通过ret弹栈并跳转。
- 这里有一个疑惑,在前面已经提到了:该段汇编代码按理说应和my_start_kernel中的汇编代码保持一致,但实际上并非如此,所以我并没有理解my_start_kernel中的代码的含义。
在state==0
时比较有意思:
1. 置入esp;
2. 程序入口ip压栈;
3. 通过ret弹栈并跳转。
这里并没有置入ebp,那么ebp如何恢复呢?
答案是ret之后再恢复。在本例中,程序入口ip即为1:
的位置,我们可以看到1:
处的代码如下:
asm volatile("popl %%ebp\n\t");my_current_task = next;printk(KERN_NOTICE ">>>switch %d to %d<<<\n", prev->pid, next->pid);
所以,在此处,程序进行了弹栈,而栈顶元素,正是在保存现场时压栈的ebp。因此,到这里,esp、ebp均已恢复,程序继续向下,即可正确执行。程序在执行完popl %ebp
及其后方的两行代码后,即从my_schedule中跳出,进入my_process的大循环中。
5 小结和疑问
通过本文的分析,可以看出操作系统在进行进程切换时,保存现场和恢复现场的基本方法。值得注意的是,进程切换过程需要使用内联汇编的方法,这是因为进程切换无法通过C的结构化方法实现。也可以看出,在汇编代码中有很多ret/push/pop指令,这些指令并没有像正常函数调用那样,成对出现。因此,分析程序的跳转方式,是理解进程切换的关键所在。
最后再次贴出我对这段源程序的疑问:
- 3.2节初始化代码中的汇编部分,和3.4节进程切换代码中的state==-1部分,做的是同样的事情(除了保存现场),为什么代码内容不同?
- 3.2节中的代码,ret后的popl %ebp是在什么时候执行呢?
期待得到解答^_^
- linux内核分析学习笔记:操作系统的进程切换
- Linux内核学习笔记之进程切换(八)
- MOOC《Linux内核分析》——进程切换的过程
- Linux内核分析:理解进程调度时机跟踪分析进程调度与进程切换的过程
- linux内核进程切换代码分析
- linux内核分析:进程切换机制
- 【Linux操作系统分析】进程——进程切换,进程的创建和撤销
- 操作系统实验之基于内核栈切换的进程切换
- Linux内核分析(八):Linux进程调度的时机和进程切换
- linux内核学习笔记:进程
- 学习Linux 0.01 内核分析和操作系统设计的准备工作
- 《Linux操作系统分析》之分析Linux内核创建一个新进程的过程
- 《Linux操作系统分析》之理解进程调度时机跟踪分析进程调度与进程切换的过程
- Linux内核进程切换
- Linux内核进程切换
- Linux内核进程切换
- Linux内核分析之八——进程调度与进程切换的过程
- java开发操作系统内核:实现进程的优先级切换
- Spark on YARN两种运行模式
- HTTP协议的学习
- 控制反转(IoC)与依赖注入(DI)
- LintCode 直方图最大矩形覆盖
- Redhat 7修改默认运行级别方法 --RHEL7使用systemd创建符号链接指向默认运行级别(文字,图形界面)
- linux内核分析学习笔记:操作系统的进程切换
- [Android]Android中Application、静态变量和Sharedpreferences的使用与区别
- 设置toolbar标题居中
- Android应用性能剖析全攻略
- 开发中碰到的问题:Unparsed aapt error(s)! Check the console for output.
- WebView之 单击手势不响应的解决方案
- Android Material Design学习之四CardView
- CSS画出一个三角形
- Swift 2.0 异常处理--throws