课程总结
经过一个学期的课程学习,一个精简的Linux操作系统应该具有以下部分:
- 中断和异常处理
- 文件系统
- 驱动程序
- 时钟系统
- 进程调度
一个可以运行的精简Linux操作系统,应该由上述的5个部分紧密结合形成。其中中断处理是核心,无论是时钟系统、文件系统、驱动程序还是进程调度,都与
中断处理有着紧密的联系,下面就对这5个部分分别进行介绍。
中断和异常处理
中断处理和异常处理是操作系统必不可少的部分,使用中断和异常处理有两个重大意义:
- 使得I/O处理和CPU可以并行操作,从而增加CPU的效率。(中断)
- 保护操作系统,涉及内核得具体操作应该对用户透明。
每个能发出中断请求的硬件控制设备都有一个指派为IRQ的输出线。所有IRQ都与中断控制器的输入引脚相连。
中断控制器(PIC)通常会执行以下动作:
- 监视IRQ线,检查产生的中断信号。
- 把接收到的信号转换成对应的中断向量。
- 把中断向量存放在中断控制器的一个I/O端口,从而允许CPU通过数据总线读取该向量。
- 把产生的信号发送到处理器的INTR管脚,即发出一个中断。
- 等待直到CPU确认该中断信号,之后把它写进一个I/O端口,同时清空INRT信号。
在允许中断前,内核必须建立IRQ号与I/O设备相对应,在新的I/O设备加入时,内核会初始化相应的驱动程序,此时会检索到设备挑选的IRQ线。
当执行了一条指令,cs和eip寄存器包含了下一条指令的逻辑地址。在执行这条指令之前,控制单元会检查在运行上一条指令时是否发生了一个中断或异常,如果发证了,控制单元将进行以下处理:
- 确定中断或异常关联的向量i
- 读取idtr寄存器指向的IDT表中的第i项。
- 从gdtr寄存器种获取GDT的基地址,根据IDT中的段描述符在GDT表中进行查找,确定中断或异常处理程序所在段的基地址。
- 进行中断或异常源的安全检查。首先将特权级CPL(存在cs寄存器低两位),与对应处理程序的段(GDT中)特权级DPL进行比较,如果CPL小于DPL则产生“一般保护性”异常,因为中断或异常处理程序的特权不能低于引起中断或异常的程序的特权。对于编程异常,还需要进一步检查CPL和IDT表中门描述符的DPL,如果DPL小于CPL则产生“一般保护性异常”,因为防止用户应用程序访问特殊的陷阱门或中断门。
- 如果CPL的特权与DPL特权不同,则控制单元必须使用与新的特权级相关的栈。通过读tr寄存器访问当前进程TSS段,来用与新特权级相关的栈段和栈指针装载ss和esp寄存器(这些值存储在TSS中),并在随后将以前得ss和esp值保存在新栈中,这些值定义了旧特权级相关得栈得逻辑地址。
- 如果指令发生了异中的故障,则用引起异常的指令地址装载cs和eip寄存器,从而使得这条指令能再次被执行。对于中断来说,cs和eip寄存器存储的是下一条指令,异常中的故障则存储得是当前指令。
- 在栈中保存eflag,cs和eip得内容。
- 如果异常产生了一个硬件错误码,则将它保存在栈中。
- 用IDT表中得第i项门描述符得段选择符和偏移量装在cs和eip寄存器,这些值代表了中断或异常处理程序得第一条指令得逻辑地址。
中断或异常处理完成后,相应的处理程序必须产生一条iret指令,把控制权交给被中断的进程,控制单元进行以下操作:
- 用保存在栈中的值装载cs、eip或eflag寄存器。如果硬件出错码曾被压入栈中,则执行iret前需要先弹出该错误码。
- 检查处理程序的CPL是否等于cs最低两位的值(属于中断嵌套),如果是则iret终止执行,否则转入下一步。
- 从栈中装载cs和esp寄存器,因此,返回到与旧特权级相关的栈(进程空间栈)。
文件系统
文件系统,就是操作系统中实现文件统一管理的一组软件、被管理的文件以及为实施文件管理所需要的一些数据结构的总称。
实现操作系统对其它各种不同文件系统的支持,就要将对各种不同文件系统的操作和管理纳入到一个统一的框架中。对用户程序隐去各种不同文件系统的实现细节,为用户程序提供一个统一的、抽象的、虚拟的文件系统界面,这就是所谓的虚拟文件系统(Virtual File System Switch,VFS)。
第一层为文件系统接口层,如open、write、close等系统调用接口。
第二层为VFS (Virtual File System)接口层。该层有两个接口:一个是与用户的接口;一个是与特定文件系统的接口。VFS与用户的接口将所有对文件的操作定向到相应的特定文件系统函数上。VFS与特定文件系统的接口主要是通过vfs-operations来实现的。
第三层是具体文件系统层,提供具体文件系统的结构和实现,包括网络文件系统,如NFS (network file system)。
VFS的基本思想是引入一个通用文件模型,这个模型能够表示所有支持的文件系统。
通用模型通常由下列对象组成:
- 超级块对象 存放文件系统相关信息,例如文件系统控制块
- 索引节点对象 存放具体文件的一般信息,文件控制块/inode
- 文件对象 存放已打开的文件和进程之间交互的信息,文件对象在执行系统调用open()时创建,执行close()时撤销。
- 目录项对象 存放目录项与文件的链接信息
通用模型中的每个对象,都对应一个操作对象,它描述了内核针对主要对象的可以使用的方法。
当用户进程打开一个文件时(如使用open系统调用),会在该进程的task_struct中进程当前工作目录和根目录相关信息以及当前打开的文件信息。
文件描述符fd用来描述打开的文件,每个进程都用一个files_struct来记录文件描述符的使用情况,这个结构称为用户打开文件表。
系统打开文件表是由file对象组成的链表,它在内核态由内核控制。用户打开文件表,是进程的私有数据,因而应用程序只能使用文件描述符作为参数的系统调用才能访问系统打开文件表。
Linux中的每个进程都有两个数据结构来描述进程与文件的相关信息,一个进程所处的位置由fs_struct结构来描述,它包含了两个指向VFS dentry的指针,分别指向root和pwd。进程打开的文件是由files_struct结构来描述,最多能同时打开的文件为OPEN_MAX个,每当打开一个文件时,就从files_struct结构中找一个空闲的文件描述符,使它指向打开文件的描述结构file,对文件的操作通过file结构中定义的操作函数的VFS inode的信息来完成。
驱动程序
驱动是应用软件和硬件得桥梁,它使得应用软件只需要调用系统软件得API就可以让硬件去完成要求得工作。
Linux设备驱动包括字符设备、块设备和网络设备。
Linux的内核模块机制给内核提供了很强的灵活性,用户通过加载内核模块方便的给内核添加功能,用户同样也可以将内核不需要用的功能卸载。用户通过内核模块机制可以动态的把需要用到的驱动程序动态地加入内核。
内核模块在内核态运行,并且只能调用和使用内核提供地函数。
Linux将硬件设备看作是一个特殊的文件来操作,该文件被称为设备文件,系统通过对设备文件的读写等操作,实现对外设的读写等操作。
驱动程序是设备文件与直接外设间的桥梁。
时钟系统
Linux必须完成两种主要的定时测量,具体区别如下:
- 保存当前的时间和日期,以便通过time(),ftime()和gettimeofday()系统调用把它们返回给用户程序。
- 维持定时器,这种机制能够告诉内核或用户程序某一时间间隔已经过去了。
所有的PC都包含一个叫RTC的时钟,它是独立于CPU和所有其他芯片的,即时PC被切断电源,RTC还继续工作,因为它靠一个小电视或蓄电池供电。
RTC能在IRQ8上发出周期性的中断,Linux只用RTC来获取时间和日期。不过,通过对/dev/rtc设备文件进行操作,也允许进程对RTC变成,内核通过0x70和0x71 I/O端口访问RTC。
所有x86微处理器都包含一条CLK输入引线,它接收外部振荡器的时钟信号。x86包含一个计数器,在每个时钟信号到来时加1,该计数器利用64位的TSC寄存器实现,可以通过汇编rdtsc读取该寄存器。
PIT通过编程可以以内核确定的固定频率不断地通过IRQ0来发出时钟中断,来通知内核又过去一个时间间隔。
PIT每次发出时钟中断地间隔叫做一个tick,它地长度以纳秒地单位存放在tick_nsec变量中。tick如果设置地短,则需要CPU在内核态花费更多地时间,因而用户程序运行地稍慢。
Linux必定执行与定时相关的操作,例如,内核会周期性地:
- 更新自系统启动以来所经过的时间。
- 更新日期和时间
- 确定当前进程在每个CPU上已运行了多长时间,进行时间片调度。
- 更新资源使用统计数
- 检查每个软定时器的时间间隔是否已到。
在单处理器系统中,所有定时活动都由IRQ0上的时钟中断触发。
内核初始化时期,time_init()函数被调用来建立计时体系结构。具体进行一下操作:
- 初始化xtime变量。
- 如果内核支持HPET(高精度定时器),则将调用那个hpet_enable()函数启用HEPT芯片,否则内核将使用PIT。
- 调用select_time()来挑选系统中可利用的最好的定时器资源,并试着cur_time变量指向该定时器资源对应的定时器对象的地址。
- 调用setup_irq(0, &irq0)来创建与IRQ0相应的中断门,IRQ0引脚连接着系统时钟中断源(PIT或HPET)。从现在起time_interrupt()函数将会在每个节拍到来时被调用。
time_interrupt()是PIT或HPET的中断服务例程,它执行以下步骤:
- 在xtime_lock顺序锁上产生一个write_seqlock()来保护与定时相关的内核变量。
- 调用do_timer_interrupt()函数,该函数执行以下操作:
- jiffies_64的值加1
- 调用update_times()来更新系统日期和时间,该函数会调用update_wall_times()来更新墙上时间。
- 调用update_process_times()为本地CPU执行几个与定时相关的计数操作。该函数调用raise_softirq()激活本地CPU上的软定时器中断任务队列,scheduler_tick()函数使当前进程的时间片计数器减一。
- 调用profile_tick()函数。
- 调用write_sequnlock()释放xtime_lock顺序锁。
进程调度
Linux中一个任务(task)就是一个进程,每个进程都具有一定的功能和权限,它们都运行在各自独立的虚拟地址空间。在Linux中,进程是系统资源分配的基本单位,也是使用CPU运行的基本调度单位。存放在磁盘上的可执行文件的代码和数据的集合称为可执行映像,当一个可执行映像装入系统中运行时,它就形成了一个进程。
进程控制块PCB是名字为task_struct的数据结构,它称为任务结构体。任务结构体中容纳了一个进程的所有信息,是系统进行进程管理的有效手段。当一个进程被创建时,系统就为该进程建立一个task_struct任务结构体,当进程运行结束时,系统撤销该进程的任务结构体。
进程的任务结构体是进程存在的唯一标志,Linux在内存空间中开辟了一个专门的区域存放进程的任务结构体。
进程结构体放在动态内存中而且和内核态的进程栈放在一个独立的8KB的内存区中。
Linux系统为每个用户进程分配了两个栈,用户栈和内核栈。当一个进程在用户空间执行时,系统使用用户栈,当在内核空间执行时,系统使用内核栈。内核进程只有内核栈,没有用户栈。
当进程从用户空间陷入到内核空间时,首先,操作系统在内核栈中记录用户栈的当前位置,然后将栈寄存器指向内核栈。内核空间的程序执行完毕后,操作系统根据内核栈中记录的用户栈位置,重新将栈寄存器指向用户栈。
Linux使用PID进程标识符来唯一标识一个进程,每个进程的PID号都存放在进程描述符的pid域中,新创建的进程通常是前一个进程的PID加1。Linux内核通过管理一个pidmap-array位图来标识当前已分配的pid号和闲置的pid号。
Linux将可运行状态的进程统一放置在运行队列中,并按照不同的优先级将可运行状态的进程放置在不同的链表中,来加速内核调度程序寻找进程的扫描效率,这种可运行状态的双向循环链表,也叫做运行队列。在多处理系统中,每个CPU都有它自己的运行队列,即自己的进程链表集,所有这些链表都由一个单独的prio_array_t数据结构来实现。
从本质上说,进程切换由两步组成:
- 切换页全局目录以安装一个新的地址空间。
- 切换内核态堆栈和硬件上下文。
尽管每个进程可以有自己的地址空间,但所有的进程只能共享CPU的寄存器。因此,在恢复一个进程执行之前,内核必须确保每个寄存器装入了挂起进程时的值,这样才能正确的恢复一个进程的执行。
硬件上下文指得时进程恢复执行前必须装入寄存器得一组数据,包括通用寄存器得值以及一些系统寄存器。
如果prev标识切换出得进程描述符,next标识切换进得进程描述符,进程切换可以理解为,保存priv上下文,用next上下文代替prev。
switch_to宏执行进程切换,schedule()函数会调用这个宏。进程切换得函数调用关系如下:schedule()->context_switch()->switch_to()->__switch_to()。
当schedule()需要暂停A进程切换到B进程时,就会由context_switch()完成全局页表项切换,由switch_to()和__switch_to()完成内核堆栈和硬件上下文切换。
在以下情况时,通常会发生进程调度:
- 进程状态发生变化时
- 处于运行态下的进程要等待某种资源
- 运行态下的进程执行完毕后,调用do_exit()终止运行并进入僵死态
- 处于等待态的进程被唤醒后,加入到运行队列当中
- 进程从运行态转入暂停态
- 进程从暂停态称为可运行态
- 当前进程时间片用完
- 进程从系统调用返回到用户态
- 中断处理完成后,进程返回到用户态时
Linux的进程调度是基于优先级的调度,Linux的进程分为普通进程和实时进程,实时进程的优先级要高于普通进程。Linux中进程的优先级是动态的,避免进程饥饿。
Linux对实时进程和普通进程采用不同的调度策略。
I/O驱动模拟
下面以I/O设备为例,描述一个精简的Linux操作系统在I/O驱动时是如何运作的。
在Linux系统中,设备驱动程序以静态方式或动态的方式注册到内核中,假设这里再内核编译时以静态方式注册到内核中,设备程序驱动程序根据编写的驱动代码自动创建
设备文件,并将设备文件与设备驱动程序相连,这一步骤也是Linux文件系统与设备驱动程序之间进行了结合,同时设备驱动程序中页定义了对于设备文件的各种操作访问函数。
设备驱动程序为了能成功运行,还需要向Linux操作系统申请中断,申请中断号一般有两种方式,一种方式是指定中断号,另一种方式是使用自动探测的方式来决定中断号。
确定中断号后,我们就紧接着需要确定中断处理函数,也就是中断服务例程ISR,通过调用request_irq来向指定中断号注册中断服务例程,注册完成后,操作系统便可以执行do_irq函数
来根据执行的IRQ来执行相应的中断服务例程对应的中断处理函数。这一步就是设备驱动程序和中断系统的相结合。
至此,一个I/O设备的驱动程序应该已经成功注册完毕,用户可以进行相应的设备操作。
假设用户态开启了一个进程来访问I/O设备对应的设备文件,则会触发系统调用,从用户态陷入到内核态,并执行由设备驱动程序注册设备文件相应的文件处理函数。加入该I/O设备是一个
可插拔的闹钟,我们通过访问其设备文件来设置闹钟到时时间。设置成功时间后,该设备驱动程序应该会创建一个动态定时器,该动态定时器。Linux在触发IRQ0引起的PIT时钟中断时,
会更新jiffies的计数值,同时在do_IRQ完成该时钟中断处理时,会检查软中断,此时会发现动态定时器注册的TIMER_SOFRIRQ,会执行该软中断处理函数,并讲动态定时器的值与当前
jiffies值进行比较,判断是否超时,从而执行设备驱动程序注册的超时函数,让闹钟开始响。