课程学习总结报告

报告要求

  • 请您根据本课程所学内容总结梳理出一个精简的Linux系统概念模型,最大程度统摄整顿本课程及相关的知识信息,模型应该是逻辑上可以运转的、自洽的,并举例某一两个具体例子(比如读写文件、分配内存、使用I/O驱动某个硬件等)纳入模型中验证模型。

  • 谈谈您对课程的心得体会,改进建议等。

  • 产出要求是发表一篇博客文章,长度不限,只谈自己的思考,严禁引用任何资料造成文章虚长。

概述

计算机加电之后,首先运行BIOS进行硬件自检,接着由Boot Loader引导操作系统内核,至此操作系统便掌握了对机器的控制权。操作系统接管硬件设备,为用户程序构建运行环境,并向用户程序提供底层服务。作为一个现代操作系统,Linux实现了内存管理,进程管理,文件管理,网络管理等核心功能。

内存寻址

操作系统之所以能始终掌握着控制权而不被“颠覆”,是因为内核代码有着比用户代码更高的权限,即内核态与用户态之分。而要弄清楚这样的权限控制是如何实现的,内存寻址是绕不开的话题。

首先,有必要区分三种不同层次的内存地址:

  1. 逻辑地址:由segment和offset组合而成,这也是机器指令中所用的地址。

  2. 线性地址:无层次的一维线性地址,是虚拟内存中的地址表示,故又称虚拟地址。

  3. 物理地址:与硬件直接对应的地址,用于内存芯片级内存单元寻址。

之所以说它们处于不同层次,因为它们存在如下的转换关系。

这种转换由CPU中的MMU中的分段单元和分页单元依次完成。可以看到,MMU是段页式内存管理的硬件基础。下面从分段和分页这两个阶段来看权限控制的实现。

一个逻辑地址由两部分组成:一个段标识符和一个指定段内相对地址的偏移量。前者即段选择符(segment selector),存储在cs​、ss、ds、es、fs和gs段寄存器中,这些寄存器的最低两位(​r0-r3)便记录着请求者特权级RPL,即当前正在执行进程的特权级。段选择符指向全局描述符表(GDT)或局部描述符表(LDT)中的某个段描述符,后者中的描述符特权级(DPL)指定了该段的权限要求。对于分段,Linux只是有限地使用。事实上,运行在用户态的所有Linux进程都使用一对相同的段来对指令和数据寻址,即用户代码段和用户数据段;相应地,还有内核代码段和内核数据段。除此之外,还有任务状态段(TSS),用于保存处理器寄存器的内容。

逻辑地址经分段单元转换为线性地址后,再由分页单元转换为物理地址。地址的转换需要借助页表完成。例如,32位的线性地址一般被分为三个域:Directory,Table,Offset。Table指向页目录的某一项,Offset指向了页表中的某一项。页目录的物理地址一般存储在cr3寄存器中。页目录项和页表项不仅包含了对应的物理地址,还存在一个User/Supervisor标志位,它指明了访问该页表或页所需的特权级。也就是说,每次对页面的访问都需要经过权限检查。

综上,在段页式内存管理中,程序每一次对段、页的访问背后,操作系统和CPU硬件都不遗余力地进行着权限检查。而操作系统在初始化时会将CPU置为保护模式,开启段页机制,并将自己置于最高权限。这样一来,用户程序的越权行为便再无可能。正因如此,内核态与用户态之间的阶级壁垒才牢不可破。

中断与异常

中断是CPU提供的一个重要的机制,它使得指令流可以被强制跳转。

狭义上的中断专指来自CPU外部的硬件中断,而来自CPU内部的特殊信号则称为异常。前者可进一步分为可屏蔽中断和非屏蔽中断;后者可以细分为终止,故障和陷阱。其中陷阱是由用户代码主动引发的中断,系统调用正是由此实现。

  • 如果发生了中断或异常,CPU会依次执行以下操作:

  1. 确定与中断或异常关联的向量i(0 <= i <= 255)。

  2. 读中断描述符表(IDT)中的第i项。

  3. 通过第i项中的段选择符在GDT中查找相应的段描述符。后者指定中断或异常处理程序的段基地址。

  4. 权限检查:比较CPL与段描述符的DPL;比较CPL与门描述符的DPL。

  5. 检查是否发生了特权级的变化,若CPL不同于段描述符的DPL(从用户态陷入内核态),则:

    1. 读取tr寄存器访问运行进程的TSS段。

    2. 装载TSS中的ss与sp到寄存器(指向当前线程的内核栈)。

    3. 在新栈中保存ss和sp之前值。

  6. 若发生的是故障则用引起异常的指令修改cs和sp寄存器的值,以使这条指令在异常处理结束后可以被再次执行。

  7. 在栈中保存flags,cs和ip的值。

  8. 如果异常产生一个异常操作码,则将它保存在栈中。

  9. 装载处理程序入口地址到cs和ip。

  • 从中断或异常返回时要执行iret指令,CPU会执行以下操作:

  1. cs、ip、flags依次出栈并装载到相应寄存器。若有硬件出错码,则将其出栈。

  2. 权限检查:比较CPL与处理程序权限等级,若相等,则结束;否则(从内核态返回用户态),执行下一步。

  3. 从栈中装载ss和sp寄存器(指向被中断线程的用户栈)。

  4. 检查并重置ds,es,fs,gs寄存器。

当然,除了上述由硬件自动保存和恢复的寄存器之外,还有很多寄存器需要手动维护(SAVE_ALL和RESTORE_ALL )。这些在栈中暂存的寄存器值一起构成了pt_regs结构。

中断处理程序依次执行以下操作:

  1. 将中断向量入栈

  2. 保存所有其他寄存器

  3. 调用do_IRQ

  4. 跳转到ret_from_intr

异常处理程序依次执行以下操作:

  1. 在内核堆栈中保存大多数寄存器的内容。

  2. 调用C语言的函数

  3. 通过ret_from_exception()从异常处理程序退出。

中断处理函数直接在被中断进程的内核栈内执行,即所谓的中断上下文。需要注意的是,Linux允许中断嵌套执行。

系统调用

由于用户程序无法直接操控硬件或改变系统状态,内核通过系统调用的方式为用户程序提供这些服务,包括对内存、进程、文件、网络的操作等等。

系统调用本质上是一个中断:用户代码在寄存器中设置好系统调用号,并执行int 0x80指令。CPU根据中断向量号0x80执行相应的处理程序。该程序实现了派分的功能:它根据用户设置的系统调用号调用相应的系统调用函数,后者当然也是运行在内核态。如此一来,只需要一条中断指令,系统就可以提供各种服务。如,在arch/x86/entry/syscalls/syscall_32.tbl里就有32位Linux系统调用的定义。

一般的系统调用执行过程与中断别无二致,这里不再赘述。但某些系统调用的实现则有一些特别之处,如fork系统调用。该系统调用会创建一个子进程,并且会返回两次:一次返回到原进程;另一次返回到新建的子进程。如何做到这一点呢?在后面的进程管理小节将会进一步介绍。

进程管理

前文中进程一直充当背景出现,这一节就来探究一下进程的实现和管理。进程代表一个执行流,它不仅绑定了CPU上下文,还关联着程序执行所需的其他各种资源。操作系统原理中PCB的概念在Linux中以task_struct结构体的形式实现。该结构体包含了一个进程实体所需的所有信息,它串联起了系统各个子模块,包括内存,文件,网络等等。

进程的创建

Linux中的所有进程构成一棵进程树,树中的子进程均由父进程创建生成。树的根节点为0号进程,除0号进程通过硬编码创建之外,其他所有进程都通过克隆父进程task_struct产生。1号进程为kernel_init,负责启动用户态的进程init,后者是所有用户进程的祖先;2号进程是kthreadd,它是所有内核线程的祖先,负责管理所有内核线程。

无论是通过fork系统调用还是直接调用内核函数,创建进程的工作最终都交由_do_fork完成。如前所述,__do_fork所做的就是把当前进程的描述符等相关进程资源复制一份,从而产生一个子进程,并根据子进程的需要对复制的进程描述符做一些修改,然后把创建好的子进程放入运行队列。

进程的切换

进程的切换的核心是几个关键寄存器的切换,主要包括:

  1. 地址空间的切换:cr3寄存器

  2. 内核栈的切换:ss和sp寄存器

  3. CPU上下文的切换:cs和ip寄存器

进程的切换由内核函数context_switch完成,它首先调用switch_mm切换cr3,然后调用宏switch_to进行内核栈和CPU上下文的切换。对于参与切换的两个进程prev和next,switch_to所做的动作大致为:

  1. 保存prev的ip

  1. 保存prev的bp和sp

  2. 恢复next的bp和sp

  3. 恢复next的ip

对于Linux3.18.6版本与5.4.34版本在实现上有较大差异,如对于ip的保存与恢复,前者采用手动编码;而后者巧妙地借助call和ret指令配合实现。

前面的系统调用小节说过,fork系统调用会返回两次。这是因为,与父进程不同,子进程堆栈中暂存的是fork_frame,它不仅包含了pt_regs,还包含了inactive_task_frame,后者的最后一个字段为ret_addr字段。如此,当子进程被切换到CPU执行时,最后的ret指令便将ret_addr弹出并装载到ip寄存器中,子进程便从该地址,即ret_from_fork处开始执行。

进程的调度

每当需要进行进程的调度时,都会调用schedule内核函数。在以下时机,schedule被调用:

  1. 进程状态发生变化时

  2. 进程时间片用完时

  3. 进程从系统调用返回到用户态时

  4. 中断处理后,进程返回到用户态时

Linux采用基于优先级的调度策略,并提供了三种不同调度策略。SCHE。对于可运行队列中的进程,Linux进一步将其划分为活动进程和过期进程:前者是用未用完时间片的可运行进程;后者是用完时间片的可运行进程。因此,调度程序所做的工作就是在活动进程集合中选取一个最佳优先级的进程,当该进程时间片用完时便加入过期进程集合。当然,对于实时进程,以及普通进程中的交互进程和批处理进程,Linux采取了一些特定的策略。

文件系统

Linux继承了Unix的思想:一切皆文件。Linux中的文件类型多种多样,有:普通文件,目录文件,符号链接,设备文件,管道文件和套接字等等。Linux中的文件在逻辑上被组织成一颗文件目录树:树的中间节点代表目录,叶子节点代表具体文件。树的根节点即系统的根目录。用户可以挂载其他文件系统到目录树中的某一个目录上,后者便成为一个挂载点。

Linux支持多种文件系统,通过引入虚拟文件系统(VFS),用户可以通过统一的操作界面和应用编程接口来使用它们。VFS提供的通用文件模型所使用的数据结构主要有:超级块(super block)对象,索引节点(inode)对象,目录项(dentry)对象,文件(file)对象。超级块对象存放已安装文件系统的信息,对应于磁盘上的文件系统控制块,每个文件系统都对应一个超级块对象。索引节点存放文件信息,对应于磁盘上的文件控制块,它与文件一一对应。目录项对应文件路径中的一部分,如某个文件目录等,它存放与对应文件进行链接的各种信息。文件对象表示一个被进程打开的文件,在open时创建,close时撤销。

我们以系统调用open的执行为例,研究文件系统如何与进程交互。在内存中存在两个重要的数据结构:系统打开文件表和进程打开文件表。前者包含每个已打开文件的文件控制块信息,后者包含指向系统打开文件表某项的指针。用户程序执行open库函数后,发起open系统调用并陷入内核态。在内核态执行函数sys_open,后者调用do_sys_open(AT_FDCWD, filename, flags, mode),具体工作如下:

  1. 找到一个本进程没有使用的文件描述符fd(int型)

  2. 分配一个全新的struct file结构体

  3. 根据传入的pathname查找或建立对应的dentry

  4. 建立fd到这个struct file结构体的联系

下面一张图很好地展现了进程与文件系统交互所用的各个数据结构及其交互关系。

心得与建议

心得体会

操作系统是软件世界的基础设施,Linux作为一款成功的开源操作系统,有很多值得学习和研究的地方。理解了Linux的基本框架和运行机制,无论是对于学习理论知识还是帮助解决现实中的问题都大有脾益。本门课程不仅让我学习到了新的知识,也帮助我澄清了过去一些概念上的模糊之处。学无止尽,今后我将继续带着好奇学习和使用Linux。

改进建议

希望课程讲解更加严谨,更具趣味性。

最后,感谢老师们的辛勤付出和耐心指导。

 

参考:

  1. 《深入理解Linux内核》第3版

posted @ 2020-07-08 13:28  smarxdray  阅读(158)  评论(0编辑  收藏  举报