本次的实验是使用gdb跟踪调试内核从start_kernel到init进程启动,并分析启动的过程。
1、首先是在实验楼虚拟机上进行调试跟踪的过程。
cd LinuxKernel
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img
![ikv9RD3.png](https://i.imgur.com/ikv9RD3.png)
使用gdb跟踪调试内核:
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S启动(窗口被冻结)
(gdb)file linux-3.18.6/vmlinux # 在gdb界面中targe remote之前加载符号表
(gdb)target remote:1234 # 建立gdb和gdbserver之间的连接,按c 让qemu上的Linux继续运行
(gdb)break start_kernel # 断点的设置可以在target remote之前,也可以在之后
![48j58Hx.png](https://i.imgur.com/48j58Hx.png)
第二步实验过程中出现连接超时的错误,原因是把qemu窗口关闭导致没连接上冻结的系统。
另开一个shell窗口(水平分割),然后gdb
![7Gbm1ey.png](https://i.imgur.com/7Gbm1ey.png)
先读取符号表,接着连接Linux系统并设断点(start_kernel)
![4ahcT2H.png](https://i.imgur.com/4ahcT2H.png)
按“c"回车,系统开始启动到start_kernel处
![FO0vfdn.png](https://i.imgur.com/FO0vfdn.png)
list”可看到start_kernel上下的代码
![rZkU7h2.png](https://i.imgur.com/rZkU7h2.png)
设断点rest_init并运行
![v1cZeU1.png](https://i.imgur.com/v1cZeU1.png)
list”可看到rest_init上下的代码(可看出它是在start_kernel函数的尾部被调用)
![7W0Cf4Z.png](https://i.imgur.com/7W0Cf4Z.png)
2、接下来简单分析一下start_kernel(这里只关注较重要的地方)
1)先打开init目录下的main.c找到start_kernel:
![HTQGoZH.png](https://i.imgur.com/HTQGoZH.png)
507行处的init_task为全局变量,相当于一个进程的pcb,0号进程为最终的idle进程。
注:不管内核的哪一方面都会涉及到start_kernel。
2)搜索trap_init,找到arch/x86下的代码:
![1nmxYo3.png](https://i.imgur.com/1nmxYo3.png)
其中的一个硬件中断:
![k16ObDn.png](https://i.imgur.com/k16ObDn.png)
这里的SYSCALL_VECTOR是一个系统调用,用指令的方式来触发中断。
3)之后有很多个初始化,需要注意的还有整个函数的最后一句rest init():
![zl4Q5Ur.png](https://i.imgur.com/zl4Q5Ur.png)
进入 rest init()中的kerne init,其中945行处有一个run_ init_process:
![jXJcSGH.png](https://i.imgur.com/jXJcSGH.png)
这也就是Linux系统中的1号进程,是第一个用户态进程,默认是根目录下的一个程序;如果根目录下没有这个进程,系统会寻找其他的默认进程作为1号进程。
总结: Linux一开始启动就通过start kernel进行初始化,其实相当于main()函数都作为入口函数,然后再到其最后一个函数调用rest init(),这期间都是初始化的过程。在rest init中,通过init task产生pid=0的进程,即0号进程,在start kernel内核一启动时就一直存在;然后这个0号进程就创建了1号进程kernel_init,接下来还创建了其他的一些服务类的内核线程如kthreadd。这样整个系统就启动起来了。内核启动过程分析参见:http://blog.csdn.net/cao75beckham/article/details/6968308;linux 内核源代码目录结构概述参见:http://doc.orz520.com/a/doc/2012/0107/2091476.html?from=haosou
2、课本学习
进程调度的一般原理
1. 进程类型:
在linux调度算法中,将进程分为两种类型,即:I/O消耗型和CPU消耗型。例如文本处理程序与正在执行的Make的程序。文本处理程序大部份时间都在等待I/O设备的输入,而make程序大部份时间都在CPU的处理上。因此为了提高响应速度,I/O消耗程序应该有较高的优先级,才能提高它的交互性。相反的,Make程序相比之下就不那么重要了,只要它能处理完就行了。因此,基于这样的原理,linux有一套交互程序的判断机制。
在task_struct结构中新增了一个成员:sleep_avg此值初始值为100。进程在CPU上执行时,此值减少。当进程在等待时,此值增加。最后,在调度的时候。根据sleep_avg的值重新计算优先级。
2. 进程优先级:
正如我们在上面所说的:交互性强的需要高优先级,交互性弱的需要低优先级。在linux系统中,有两种优先级:普通优先级和实时优先级。我们在这里主要分析的是普通优先级,实时优先级部份可自行了解。
3. 运行时间片:
进程的时间片是指进程在抢占前可以持续运行的时间。在linux中,时间片长短可根据优先级来调整。进程不一定要一次运行完所有的时间片。可以在运时的中途被切换出去。
4. 进程抢占:
当一个进程被设为TASK RUNING状态时,它会判断它的优先级是否高于正在运行的进程,如果是,则设置调度标志位,调用schedule()执行进程的调度。当一个进程的时间片为0时,也会执行进程抢占。
5.Linux 调度器将进程分为三类:
1)交互式进程
此类进程有大量的人机交互,因此进程不断地处于睡眠状态,等待用户输入。典型的应用比如编辑器 vi。此类进程对系统响应时间要求比较高,否则用户会感觉系统反应迟缓。
2)批处理进程
此类进程不需要人机交互,在后台运行,需要占用大量的系统资源。但是能够忍受响应延迟。比如编译器。
3)实时进程
实时对调度延迟的要求最高,这些进程往往执行非常重要的操作,要求立即响应并执行。比如视频播放软件或飞机飞行控制系统,很明显这类程序不能容忍长时间的调度延迟,轻则影响电影放映效果,重则机毁人亡。
根据进程的不同分类 Linux 采用不同的调度策略。对于实时进程,采用 FIFO 或者 Round Robin 的调度策略。对于普通进程,则需要区分交互式和批处理式的不同。传统 Linux 调度器提高交互式应用的优先级,使得它们能更快地被调度。而 CFS 和 RSDL 等新的调度器的核心思想是“完全公平”。这个设计理念不仅大大简化了调度器的代码复杂度,还对各种调度需求的提供了更完美的支持。
6.调度发生两种形式
1. 主动式调度(自愿调度)
在内核中主动直接调用进程调度函数schedule(),当进程需要等待资源而暂时停止运行时,会把状态置于挂起(睡眠),并主动请求调度,让出cpu。
2. 被动式调度(抢占式调度、强制调度)
pc是怎样把操作系统从硬盘装载到内存中,并启动进程调度模块的。然后才是后面对schedule的具体分析。
7.Linux启动调度模块前动作
首先,启动操作系统部分,涉及到到三个文件:/arch/i386/boot/bootsect.s、/arch/i386/boot/setup.s、/arch/i386/boot/compressed/head.s。编译安装好一个Linux系统后,bootsect.s模块被放置在可启动设备的第一个扇区(磁盘引导扇区,512字节)。在经过一系列过程后,程序跳转到system模块中的初始化程序init中执行,即/init/main.c文件。该程序执行一系列的初始化工作,如寄存器初始化、内存初始化、中断设置等。此后,CPU有序地从内存中读取程序并执行。前面的main从内核态移动到用户态后,操作系统即建立了任务0,即进程调度程序。之后再由schedule模块进行整个Linux操作系统中进程的创建(fork),调度(schedule),销毁(exit)及各种资源的分配与管理等操作了。值得一说的是schedule将创建的第一个进程是init(pid=1),请注意它不是前面的/init/main.c程序段。如果是在GNU/Debian系统下,init 进程将依次读取rcS.d,rcN.d(rc0.d~rc6.d),rc.local三个run command脚本等,之后系统的初始化就完成了,一系列系统服务被启动了,系统进入单用户或者多用户状态。然后init 读取/etc/inittab,启动终端设备((exec)getty)供用户登陆,如debian中会启动6个tty,你可以用组合键ctrl+alt+Fn(F1~F6)来切换。到这里就知道了Linux怎样启动进程调度模块了,也知道了进程调度模块启动的第一个进程init及之后的系统初始化和登陆流程。
下面就来分析schedule代码及其相关函数调用。
就绪进程选择算法(即进程调度算法)文件:/kernel/sched.c
1. 上下文切换
从一个进程的上下文切换到另一个进程的上下文,因为其发生频率很高,所以通常都是调度器效率高低的关键。schedule()函数中调用了switch_to宏,这个宏实现了进程之间的真正切换,其代码存放于include/i386/system.h。switch_to宏是用嵌入式汇编写成的,较难理解。
由switch_to()实现,而它的代码段在schedule()过程中调用,以一个宏实现。
switch to()函数正常返回,栈上的返回地址是新进程的task_struct::thread::eip,即新进程上一次被挂起时设置的继续运行的位置(上一次执行switch to()时的标号"1:"位置)。至此转入新进程的上下文中运行。
这其中涉及到wakeup,sleepon等函数来对进程进行睡眠与唤醒操作。
2. 选择算法
Linux schedule()函数将遍历就绪队列中的所有进程,调用goodness()函数计算每一个进程的权值weight,从中选择权值最大的进程投入运行。Linux的调度器主要实现在schedule()函数中。调度步骤Schedule函数工作流程如下:
(1)清理当前运行中的进程
(2)选择下一个要运行的进程(pick_next_task)
(3)设置新进程的运行环境
(4)进程上下文切换
8.Linux进程优先级
进程提供了两种优先级,一种是普通的进程优先级,第二个是实时优先级。前者适用SCHED NORMAL调度策略,后者可选SCHED FIFO或SCHED RR调度策略。任何时候,实时进程的优先级都高于普通进程,实时进程只会被更高级的实时进程抢占,同级实时进程之间是按照FIFO(一次机会做完)或者RR(多次轮转)规则调度的。
(1)Linux对普通的进程,根据动态优先级进行调度。而动态优先级是由静态优先级(static prio)调整而来。Linux下,静态优先级是用户不可见的,隐藏在内核中。而内核提供给用户一个可以影响静态优先级的接口,那就是nice值,两者关系如下:static prio=MAXRT PRIO +nice+ 20`nice值的范围是-20~19,因而静态优先级范围在100~139之间。nice数值越大就使得static_prio越大,最终进程优先级就越低。ps -el 命令执行结果:NI列显示的每个进程的nice值,PRI是进程的优先级(如果是实时进程就是静态优先级,如果是非实时进程,就是动态优先级)
(2)实时进程,只有静态优先级,因为内核不会再根据休眠等因素对其静态优先级做调整,其范围在0~MAX RT PRIO-1间。默认MAX_RT_PRIO配置为100,也即,默认的实时优先级范围是0~99。而nice值,影响的是优先级在MAX_RT_PRIO~MAX_RT_PRIO+40范围内的进程。不同与普通进程,系统调度时,实时优先级高的进程总是先于优先级低的进程执行。知道实时优先级高的实时进程无法执行。实时进程总是被认为处于活动状态。如果有数个 优先级相同的实时进程,那么系统就会按照进程出现在队列上的顺序选择进程。假设当前CPU运行的实时进程A的优先级为a,而此时有个优先级为b的实时进程B进入可运行状态,那么只要b<a,系统将中断A的执行,而优先执行B,直到B无法执行(无论A,B为何种实时进程)。不同调度策略的实时进程只有在相同优先级时才有可比性:
1. 对于FIFO的进程,意味着只有当前进程执行完毕才会轮到其他进程执行。由此可见相当霸道。
2. 对于RR的进程。一旦时间片消耗完毕,则会将该进程置于队列的末尾,然后运行其他相同优先级的进程,如果没有其他相同优先级的进程,则该进程会继续执行。
9.公平调度
(1)原理: cfs定义了一种新的模型,它给cfs_rq(cfs的run queue)中的每一个进程安排一个虚拟时钟,vruntime。如果一个进程得以执行,随着时间的增长(也就是一个个tick的到来),其vruntime将不断增大。没有得到执行的进程vruntime不变。而调度器总是选择vruntime跑得最慢的那个进程来执行。这就是所谓的“完全公平”。为了区别不同优先级的进程,优先级高的进程vruntime增长得慢,以至于它可能得到更多的运行机会。
(2)CFS基本设计思路
进程的运行时间计算公式为:
分配给进程的运行时间 = 调度周期 * 进程权重 / 所有进程权重之和
调度周期很好理解,就是将所有处于TASK_RUNNING态进程都调度一遍的时间,差不多相当于O(1)调度算法中运行队列和过期队列切换一次的时间.举个例子,比如只有两个进程A, B,权重分别为1和2,调度周期设为30ms,那么分配给A的CPU时间为:30ms * (1/(1+2)) = 10ms;而B的CPU时间为:30ms * (2/(1+2)) = 20ms。那么在这30ms中A将运行10ms,B将运行20ms。
公平怎么体现呢?它们的运行时间并不一样阿?
其实公平是体现在另外一个量上面,叫做virtual runtime(vruntime),它记录着进程已经运行的时间,但是并不是直接记录,而是要根据进程的权重将运行时间放大或者缩小一个比例。我们来看下从实际运行时间到vruntime的换算公式:
vruntime = 实际运行时间 * 1024 / 进程权重
这里直接写1024,实际上它等于nice为0的进程的权重,代码中是NICE_0_LOAD。也就是说,所有进程都以nice为0的进程的权重1024作为基准,计算自己的vruntime增加速度。还以上面AB两个进程为例,B的权重是A的2倍,那么B的vruntime增加速度只有A的一半。现在我们把公式2中的实际运行时间用公式1来替换,可以得到这么一个结果:vruntime = (调度周期 * 进程权重 / 所有进程总权重) * 1024 / 进程权重 = 调度周期 * 1024 / 所有进程总权重 。没错,虽然进程的权重不同,但是它们的 vruntime增长速度应该是一样的 ,与权重无关。好,既然所有进程的vruntime增长速度宏观上看应该是同时推进的,那么就可以用这个vruntime来选择运行的进程,谁的vruntime值较小就说明它以前占用cpu的时间较短,受到了“不公平”对待,因此下一个运行进程就是它。这样既能公平选择进程,又能保证高优先级进程获得较多的运行时间。这就是CFS的主要思想了。或者可以这么理解:CFS的思想就是让每个调度实体(没有组调度的情形下就是进程,以后就说进程了)的vruntime互相追赶,而每个调度实体的vruntime增加速度不同,权重越大的增加的越慢,这样就能获得更多的cpu执行时间。
(3)CFS调度实现
CFS调度算法实现的四个组成部分:
1. 时间记账
2. 进程选择
3. 调度器入口
4. 睡眠和唤醒
CFS 维护了一个以时间为顺序的红黑树。 红黑树是一个树,具有很多有趣、有用的属性。首先,它是自平衡的,这意味着树上没有路径比任何其他路径长两倍以上。 第二,树上的运行按 O(log n) 时间发生(其中 n 是树中节点的数量)。这意味着您可以快速高效地插入或删除任务。
![MTeQeQH.gif](https://i.imgur.com/MTeQeQH.gif)
任务存储在以时间为顺序的红黑树中(由 sched_entity 对象表示),对处理器需求最多的任务 (最低虚拟运行时)存储在树的左侧,处理器需求最少的任务(最高虚拟运行时)存储在树的右侧。 为了公平,调度器先选取红黑树最左端的节点调度为下一个以便保持公平性。任务通过将其运行时间添加到虚拟运行时, 说明其占用 CPU 的时间,然后如果可运行,再插回到树中。这样,树左侧的任务就被给予时间运行了,树的内容从右侧迁移到左侧以保持公平。 因此,每个可运行的任务都会追赶其他任务以维持整个可运行任务集合的执行平衡。
Linux常用数据结构
1.链表
链表是非常基本的数据结构,根据链个数分为单链表、双链表,根据是否循环分为单向链表和循环链表。通常定义定义链表结构如下:
typedef struct node
{
ElemType data; //数据域
struct node *next; //指针域
}node, *list;
链表中包含数据域和指针域。链表通常包含一个头结点,不存放数据,方便链表操作。单向循环链表结构如下图所示:
![06ePYID.png](https://i.imgur.com/06ePYID.png)
双向循环链表结构如下图所示:
![az5HJ6Y.png](https://i.imgur.com/az5HJ6Y.png)
这样带数据域的链表降低了链表的通用性,不容易扩展。linux内核定义的链表结构不带数据域,只需要两个指针完成链表的操作。将链表节点加入数据结构,具备非常高的扩展性,通用性。链表结构定义如下所示:
struct list_head {
struct list_head *next, *prev;
};
链表结构如下所示:
![dRtfs5Y.png](https://i.imgur.com/dRtfs5Y.png)
2.队列
队列也是一种链表,只是针对队列的操作只能是从队尾插入,从队首删除。在操作系统中有很多这种数据结构的用武之地,一般是一个进程产生数据,另外一个进程处理数据,如Linux中网络数据包的处理,进程之间使用管道通信等,都是这种情况。Linux内核中队列称作kfifo,其对应的源文件时kernel/kfifo.c,<linux/kfifo.h>中包含了其声明。
struct __kfifo {
unsigned int in;
unsigned int out;
unsigned int mask;
unsigned int esize;
void *data; };
kfifo提供了两种操作,入队(in)和出队(out),为了记录下一次出队或者入队的位置,kfifo维护了两个变量in和out。入队操作会将数据拷贝至队列中,具体位置由in确定,然后根据数据大小更新in,标识下一入队发生的位置。出队的操作与之类似。当in和out相等时,队列为空,此时不能执行出队操作。当in等于队列长度时,不能执行入队操作。
3.红黑树
红黑树是一种在插入或删除结点时都需要维持平衡的二叉查找树,并且每个结点都具有颜色属性:
1. 一个结点要么是红色的,要么是黑色的。
2. 根结点是黑色的。
3. 如果一个结点是红色的,那么它的子结点必须是黑色的,也就是说在沿着从根结点出发的任何路径上都不会出现两个连续的红色结点。
4. 从一个结点到一个NULL指针的每条路径上必须包含相同数目的黑色结点。
![jKrR95g.png](https://i.imgur.com/jKrR95g.png)
Linux内核红黑树的算法都定义在linux-2.6.38.8/include/linux/rbtree.h和linux-2.6.38.8/lib/rbtree.c两个文件中。
小结:
Linux进程调度以及数据结构部分较难理解,分析较浅。想再通过阅读资料分析源代码做进一步学习。
3.未解决的问题:
![DQRgySX.png](https://i.imgur.com/DQRgySX.png)
![R0668vB.png](https://i.imgur.com/R0668vB.png)
![aavgSmF.png](https://i.imgur.com/aavgSmF.png)
最后make出错,查资料后未解决。