《linux内核设计的艺术》学习笔记

本文最后更新于:2024年11月18日 晚上

“今日归来不晚,彩霞濯满天,明月作烛台。”

0.前言

一些想说在前面的话……

很久之前就想学linux内核了,从大二就准备学,一直拖,一直拖,一直拖到大四。

其中自己也尝试着学了点,看了点A3的博客,学了点简单的内核栈的打法,也能做出来一些简单题,但是总是觉得自己好像缺了些什么东西,对于内核一直不能观其全貌。

于是大三那会就打算找些书看,成体系地学一学,但是终归是没能学下去。毕竟前三年有太多太多的事情要做,于我自身而言,我需要抓绩点,需要保研推免;于团队而言,国赛半决赛基本用不到内核题来分隔高下。因此先前的训练一直都集中于常规用户态题目的训练,最终变成了一个做常规题砍瓜切菜但面对难题会手足无措的大学做题家。

想来倒也可笑,明明是大学,却依旧逃不出应试的怪圈。

不过可喜可贺的是现在我总算是把推免拿下了,也算是侥幸快过命运一个身位。让我有时间真正地去学习,去做一些真正有意义的事情。正好内核也成为了读博的主线任务之一,于是便有可以无所顾忌地深入学习一些东西,于是就有了这篇博客。

集中精力读完整本书大概用了一周的时间,尽管先前有些基础,但是依旧算是囫囵吞枣,关掉pdf的时候还是感觉脑子乱糟糟的。因而决定整理一篇笔记,自己理一理知识点,也算是秉承互联网精神,分享一下学习经验。其实写前言之前我已经写了很多关于这本书的笔记,不过合上书再看,这些东西不过是把书上的内容又摘抄了一遍,我个人的思想理解并未融入其中,空有文字形式。于是决定全部删掉,推倒重来,结合着书本,从我的角度把我所见的内核给展现出来。

so,从明天2024.10.30开始,本站又要开始继续更新了。更新的内容不仅限于这篇文章,还有之前答应学弟写的glibc堆攻击教程,以及一切可能的稀奇古怪的技术文章。

(当然还会更新一些无关技术的神秘彩蛋ww)

关于内核的学习

想阅读本篇博客,你需要且必须要具备以下基础:

  1. C语言基础。除了基础语法之外,还需要熟练掌握指针的使用,以及要了解一些常用的底层函数的原型和功能(read,write,open等)。
  2. 汇编语言基础。这里建议阅读王爽老师的《汇编语言(第四版)》,这本书是笔者入门pwn读的第一本书。不过源码中汇编部分语法似乎是AT&T风格的,可能需要再适应一下。注意书里提到的linux是基于32位机器的。
  3. 操作系统基础。建议看西安电子科技大学的教材,教材名字我忘了(印象里是叫《计算机操作系统》)。我们学校操作系统课就用的这本教材,笔者感觉写的挺好(虽说我期末就学了一天也没怎么细看……)

各位师傅(习惯性觉得读者都是打CTF的)最好还能一定的linux使用和编程经验,这会让你理解起来更加容易,不然关于内核的学习很容易成为空中楼阁。

读完这篇博客并不能让你直接熟悉linux 0.11,实际上这篇博客的意义更多在于笔者个人的知识梳理,以及对书中内容作一些补充。一些不算太过重要,容易理解的细节,甚至不会再定量介绍。建议信任我的师傅们可以一边看书一边读我这篇博客(作为榨菜>_<)。

电子书pdf资源我回来会上传到服务器上!!(如果我有空捣鼓的话)

1.开机加电到main

从0开始的加电启动

现在摆在面前的是一台未加电的设备,所有的数据都贮存在硬盘内,内存中什么都没有。我们知道计算机的运行时,指令的执行是由CS和IP定位内存中的指令来取指令执行的。而内存中现在没有东西,操作系统代码都存在硬盘中。因此必须要先把硬盘中的代码加载进内存,而这个操作只能依托于嵌入到硬件中的程序来完成。

这个嵌入到内存中的程序就是是BIOS,BIOS的入口地址默认在0xffff:0x0000的位置。开机加电时,硬件电路会将CS和IP分别设置为0xffff和0x0000,进而执行BIOS代码。

加电后,CS:IP指向了BIOS指令。现在计算机是一台没有操作系统的裸机,也就是说它并不具备OS的特征,从上往下看可以直接看到他在机械地执行汇编指令。在这里要强调一下,linux 0.11是运行在一个支持32位的机器,但是现在机器只是运行在一个16位的状态,即实模式。运行起来操作系统时,机器会转变为保护模式,并实现32位寻址。

说到OS就离不开进程这个概念,但是现在的机器仅仅有一个执行流,并不像进程一样有一个完整的边界,在向保护模式转变的过程中,这个执行流会让自己逐渐成为一个进程。同样的,也会逐步实现OS中提到的分页等机制。

BIOS装载bootsec

BIOS只是一个小芯片,容量有限。因此BIOS一个的任务是把硬盘里的代码加载进内存(来创造无限的可能)。它的另一个重要任务是设置好中断向量表和中断服务程序。(如硬盘操作等都要涉及到中断)

当然BIOS的操作不止于此,作为硬件部分的关键一环,还会涉及到很多硬件检测的任务,但是这和OS就没有关系了。

中断向量表建立在内存最0x00000的位置,即内存的开头,这和汇编里学的一模一样。BIOS会建立0x100个中断向量,每个中断向量占据四个字节(机器字长)。而中断服务程序也相应地会被加载到稍微靠后一些的位置。

之后,BIOS会使用int 0x19这个中断(启动加载服务程序),将硬盘第一个扇区的内容加载进内存中。这个扇区里存放着bootsec代码。这里的代码会负责进一步地讲硬盘中的程序加载进内存。

注意,这里的加载是依托于中断,而中断来自于BIOS,BIOS属于硬件设备,也就是说这操作和Linux无关,属于硬件的规范。不管什么操作系统,都得把自己的bootsec放在硬盘的第一个扇区内。

bootsec加载setup和system

bootsec负责进一步将硬盘中的内核代码加载进内存,但是这里要注意对内存空间进行一些规划,通过宏定义定义了一些代码模块的地址(包括根设备号root_dev也在这里定义了存放地址)。

bootsec会对自己进行一个自我复制,把自己腾到另一个较为靠后的内存空间(精打细算的操作,复制之后的地址将来会在bootsec执行完之后被一些重要数据覆盖掉)。然后调用int 0x13这个中断(磁盘服务程序)把后面四个扇区的setup程序加载到内存,注意使用这个中断需要用通用寄存器传入一些参数来指定扇区。setup会加载到迁移后的bootsec的尾端部分。同时也会设置SS和SP,即为机器开辟一个栈空间。

之后bootsec用同样的办法把后面几百个扇区里存放的system(内核代码)加载进内存,不过这里加载的位置比较靠前(0x10000),当然了,这个内核以后还是要挪动位置的。

然后bootsec会确定软盘为根设备,保存在根设备号root_dev中。后面会有大用。

关于什么是根设备可以看13页的小贴士

setup到内核正式启动

setup会提取一些重要的机器数据到内存,这些数据正好会占据刚刚执行过的bootsec的空间。

之后,机器在实模式下需要做的事情已经都完成了。实模式中断设置好了,机器信息也提取了,内核代码页加载好了,接下来要做的是向保护模式开始转变,准备加载操作系统。

关于什么是保护模式,可以参考:CPU的实模式和保护模式(一)

关中断并迁移内核

保护模式下,会启用一套新的寻址方式和中断触发方式,因此需要重新设置中断相关的代码。在这个过程中,中断服务无疑是不能保证正常且完整的,为了确保不会在这个转变过程中触发中断而引发意外情况,这里需要通过cli指令关闭中断。等到中断服务重启之后再使用sti指令启用。

cli的原理是修改eflags寄存器中的某一位来标记关中断,sti同理。而eflags是属于后面提到的进程的上下文的——也就是说进程切换也会影响到中断开关状态。

中断向量表是在内存的开头,但是现在中断被弃用了,并且以后会用保护模式的中断,所以这里实模式中断相关的代码数据就可以废弃了。内存开头是一个地理位置非常优越的地址,所以要把内核迁移到内存开头。

建立中断描述符表和全局描述符表

使用setup自身代码设置GDT(全局描述符表)和IDT(中断描述符表),以及指向他们的gdtr(GDT基址寄存器)和idtr(IDT基址寄存器)两个寄存器。

这里要谈一下保护模式下的寻址方式

学习汇编的时候我们会知道,逻辑地址段寄存器:偏移地址的形式,如代码定位就是以CS:IP为逻辑地址,而 CS * 0x10 + IP 就可以计算出物理地址并定位到内存单元。

而保护模式下情况有所改变,逻辑地址仍然是CS:IP的形式,但是逻辑地址不会直接通过 CS * 0x10 + IP 转化为物理地址,而是先变为线性地址。线性地址的变化也不是 CS * 0x10 + IP ,实际上CS等段寄存器里存放的不是段起始地址,而是段选择符。段选择符里存放着这个段在全局描述符表GDT(或局部描述符表LDT,这个从段选择符里会指出)中的索引,而段起始地址等信息存于GDT或LDT的对应表项——即段描述符——的内部。寻址时,会通过段寄存器找到段描述符中存放的段起始地址,再和偏移地址相加得到线性地址,这就是分段机制。

如果不开启分页的话,这里的线性地址就是物理地址。如果开启了分页,线性地址要再通过页表映射才能得到物理地址,并完成对内存单元的定位。

总结一下,段寄存器存放段选择符,gdtr和ldtr指向全局或局部描述符表,里面存放段描述符,段描述符(占据8个字节)里存放段的详细信息,包括起始地址、限长等。段选择符指出要用哪个描述符表,并指出来用哪一项。然后从对应描述符对应表项中取出段描述符,解析出来信息,然后用于线性地址计算。

段寄存器的段选择符,低两位是标记权限(00是内核态,11是用户态),第三位标记是使用GDT还是LDT,再往前则是段描述符在表内的索引。

一般处于内核态的进程都是用GDT定位,将来在分析syscall陷入内核态的行为时会再讲。

在设计内核的时候,GDT和IDT已经预先写好并填入了一些必要的数据了。GDT的第一项为空,第二项为内核代码段描述符,第三项是内核数据段描述符。而IDT里面没有设置,是一个空表(因为现在是关中断,没有必要写入,仅仅占位)。

之后,打开A20寄存器,以支持32位的寻址。然后重新编程8259A寄存器以让其支持保护模式。然后讲CR0的第0位(PE)置为1,从此正式开启保护模式,分段机制正式启动。

执行内核头部的head.s

通过 jmpi 0,8 这个指令跳转到head.s,这个指令会让EIP变为0,让CS变为8。8即二进制10000,就是内核权限GDT的第二个表项,即内核代码段。而内核代码段开头(以及数据段)起始地址为0,即从内存开头地址,也是内核的head.s的位置开始执行。

重建GDT

这段代码是内核的一个头部,他最大的任务是开启分页机制。当然,在此之前要让所有数据相关的段寄存器以内核态权限指向GDT的第三项(下标为2)。同时也设置内核栈位置,设置esp指针和ss寄存器(ss也是指向GDT[2]),esp会处于一个相对远离的位置,防止压栈干扰一些关键数据。

然后重新设置GDT,这个GDT是head.s定义的现成的表,直接让gdtr寄存器指向它就好了。这里重建GDT时,段限长由原来的8MB变为了16MB。

迁移的目的是让GDT占据物理内存中一个较为靠前的位置,在setup执行时,这个地址是head.s的地址。如果不迁移,那么setup将来被废弃掉,GDT就会被破坏,由不可能单独留着一小块GDT,所以必须要迁移。但是因为head.s也要执行,所以GDT的迁移又不能在setup中完成。因此只能由head.s负责。

然后检查一下A20是不是正常打开了,这里利用了一个回滚机制。随后将main函数地址压栈,准备在返回时进入main。

启用分页

之后内核会跳转到head.s第6个页面之后的一段代码去启用分页机制。head.s通过标号标记了开头位置,又占位符占用了第2到第5个页面。由于第1个页面的代码已经执行完了,现在执行流在第6个页面,因此第一个页面已经不会再用了。于是前五个页面就会作为页表来使用。其中第1个页面作为页目录表,后4个页面作为页表。

这是一台32位机器,寻址空间为0 ~ 2^32-1。翻译到分页前的线性地址也是32位的。在linux 0.11中,内核页大小为0x1000,即4KB = 4096 = 2^12。因此页内偏移需要12位来标记。而每个页表可以存放4KB / 4 = 1024个页地址,这可以覆盖1024 * 4KB = 4 MB的地址范围。因为linux 0.11设计的是最大支持16MB的内存,所以需要四个页表,即四个页目录项。(空间实在是太小了,由此可以看出linux 0.11确实比较稚嫩)。

四个页目录项占据一个页目录表看起来挺奢侈的,实际上没有这种好事。这个页目录表会供所用正在运行的进程共用。因此线性地址的中供页目录项索引的地址,页目录项里可以存放4KB / 4 = 1024 = 2^10 个页表地址,因此需要10位作为页目录项索引。

对每个页框地址都填入对应的页表项,将每个页表的地址都填入对应页目录项,完成内核页表的构建。然后再将页目录项地址加载进CR3作为页目录项基址,将CR0的最高位(31位)置为1,启用分页机制。

然后使用ret指令将先前压栈的main地址弹出。正式启动内核。

这里注意,这一个页表虽说是把线性地址映射到物理地址,但是实际上,线性地址是完全和物理地址相同对等的,这点你可以手动算一下。不过仅仅是内核页表是这样,因为内核要映射整个内存空间。后面用户进程的页表就只能映射局部内存了。

2.从main到怠速

从main函数开始,正式开始运行内核。当前机器已经开启了保护模式,但是很多东西都还没有设置好,保护模式的中断还没建立,当前的执行流也没有编程一个完整的进程,缓冲基址和页面管理等也没有建立起来。

实现怠速依赖三个进程,进程0、进程1、进程2。其中,进程0会让机器拥有32位下以进程为单位进行运算的能力。进程1会加载文件系统,能够让计算机以文件形式管理硬盘中的数据。进程2则会运行shell程序,进而真正实现怠速,完成整个操作系统的启动。

整个linux 0.11操作系统的功能在这三个进程的创建和运行过程中几乎全部得到了展现。当然,这不代表所有操作系统的细节都包含在其中,但是对于从宏观上了解Linux 0.11的关键设计已经足够了。

进程0的任务

其实现在执行流并不能称之为进程0,它的任务就是让自己成为一个进程并具备进程应该有的基本能力。换句话说,其实进程0在形成的时候就已经把它的使命几乎完成了(致敬),

硬盘信息保存

之前bootsec中定义了根设备号存放在内存中的的信息,现在需要把它保存在内核为其规划好的地方。同样也要备份硬盘参数表。

内存规划

根据物理内存的大小,对内存区域进行规划。

前1MB一定是属于内核区域的。之后的区域会作为缓冲区,用于I/O操作的缓冲。在后面的地址会作为主内存区。

如果启用了虚拟盘,我们还需要在主内存区靠前的位置开辟一段虚拟盘区域,并把请求项处理函数do_rd_request与请求项控制结构blk_dev[7]的第一项进行挂接。之后将虚拟盘占据的内存全部清0,并重新设置主内存区的长度。

使用mem_map结构管理所有物理内存页面的使用情况。先将所有页的引用次数记录为100,再将主内存区的应用次数更改为0。mem_map中,引用次数为0的页面即被视为空闲页。而内核代码和关键结构占据的页面未被清0,也就不会被其他进程占用,防止被破坏。

重建中断服务

IDT表在head中也被定义了,但是这里只是一个空表,中断服务并未挂接在其中。因此需要将如下的中断服务挂在进去。挂载过程是生成对应的中断描述符并写入IDT中。中断描述符长8个字节,类似前面提到的段描述符,除了存放中断服务程序地址之外,还会保存其他的一些信息。而针对中断服务程序的定位也类似于段描述符定位段地址的方式。

需要构建的中断服务如下,注意这些任务在源码中不是连起来做的:

  1. 异常处理中断(除0错误等)
  2. 人机交互外设中断(键盘显示屏串口等)
  3. 时钟中断(挂在轮询服务并设置定时器)
  4. 系统调用
  5. 软盘硬盘请求服务与中断服务(在软硬盘的初始化中进行,由进程0负责,后面不提了)

初始化请求项和缓冲区结构

这两个部分和缓冲机制有关(当然,它们也不是连续完成的,源码里他们和中断以及激活进程0是混在一起操作的)。缓冲的意义在于让主内存和机器彼此之间不再直接接触,主内存只需要考虑现在可不可以和缓冲区交互,而外设也只需要考虑能否和缓冲区交互,这样主内存和外设就彼此独立工作了。

请求项

request[32],请求项管理结构,这个结构保存硬盘请求,它记录了机器操作哪个设备,操作设备中的哪个块。request的dev成员标志请求项是和哪个设备绑定(-1为未绑定设备),而next成员是一个request指针,用于构建请求项等待队列。

一般来说,用户更加急着看到读取数据的结果而非写入数据的结果。所以后1/3的请求项特供于读操作,而剩下的部分由读和写操作共用。

缓冲区

缓冲区是用于数据交换的空间,整个缓冲区由空闲链表和哈希表两个数据结构共同管理。

空闲表是一个双向链表,其意义在于快速找到一个可以供我们使用的缓冲块。缓冲块会有引用次数、锁定标记、“脏”标记、更新标记等属性,初始化时全部设置为0。

然后对哈希表进行设置。因为笔者数据结构学的稀烂,所以也不是很懂怎么实现。只知道这个结构的意义在于,某些时候我们想操作的硬盘块已经被加载进内存了,通过哈希表我们能快速尝试找到我们想操作的硬盘数据块,如果找得到已经加载进来的对应数据块,就无需再次读取了。

激活进程0

进程信息加载

进程0的task_struct是在内核设计阶段就填写好的,task_struct这个结构保存进程的很多重要属性信息。进程0的task_struct会加载到进程管理结构的task[64]的0号位置。

另一个重要的结构是TSS,这个结构记录了进程运行的状态,一般会在进程切换的时候保存当前进程的TSS,并调出待切换的进程的TSS加载进程上下文。在这里,进程0的TSS和LDT会被挂载到GDT当中,同时tr和ldtr中也会加入TSS描述符和局部描述符

GDT除了前四项用于内核数据之外,之后两项就会供给一个进程使用,分别用于挂载TSS和LDT描述符。这一点可以从挂载函数的偏移中得到证实。

时钟中断和轮询

激活进程0,还需要设置好上面说的时钟中断,这是为了支持轮询。

首先要设置好定时器,然后要挂载好时钟中断函数(轮询服务)。然后要激活定时器,让定时器定时发出中断信号。不过,由于没有开中断,机器现在不会响应中断信号,也不会触发轮询和进程调度。

至此,进程0已经初具雏形,也基本完成了它的使命。

准备开中断

sti()

进程0创建进程1——fork系统调用

需要为进程1准备什么?

linux对于进程的创建是通过fork系统调用来实现的,通过fork可以复制一个已有的进程,再由fork在父子进程中返回值不同区分父子进程,进行不同的处理(一般来说是使用execve系统调用加载另一个程序)

当前情况下(其他一般情况也需要考虑),由进程0创建一个进程1,需要为其创建task_struct结构并挂载到合适的task结构中的空闲项,并设置好TSS结构。

一般情况下还需要考虑和文件相关的结构,不过0号进程暂时还没有做好这些东西。

切至用户态再陷入内核态

首先,fork是系统调用,是内核提供给用户态进程的接口。为了遵循这个规则,需要将0号进程先切换到用户态。这里使用iret函数实现,iret会将栈上预先存留的数据存入对应寄存器实现内核态和用户态的切换(涉及到cs,eip,ss,esp,eflag),正常情况下这些数据是刚切换进内核态的过程中保存在栈上的,这里因为进程0诞生于内核态,因此需要手动设置让进程0以用户态权限使用其LDT表项。

然后使用fork系统调用,先进入内核中的system_call汇编片段,保存当前进程的段寄存器并将系统调用传入的参数压栈。然后将ds切换指向GDT表下标为2的表项。这也意味着任何进程切入内核态后,在寻址上会使用直接GDT的1和2号索引,而无关于用户态时进程独有的LDT

之后根据eax确定系统调用号,在系统调用表中找到对应响应函数。

find_empty_process寻找task中空闲项

内核中有一个last_pid的int型变量,其初值为0,每次创建一个进程,都会试图用last_pid+1这个数值作为进程的pid,这个pid会成为一个进程的标识。创建进程会让last_pid不断增大,当发生上界溢出的时候,last_pid会在创建进程时重设为1并尝试给予这个新进程。

每次试图将last_pid+1作为新进程的pid时,都需要检查task结构中有没有某项对应进程的pid与其冲突。如果有的话,就让last_pid自增后重新上述测试。若能成功通过,则从task中取最靠前的一个空闲项的索引,将来会把进程的task_struct挂载到其中。这个空闲项索引区分于nr,注意于last_pid区分。

copy_process 创建进程1

寄存器保存

在内存中找到一个空闲页面,用于创建进程1的task_struct和TSS结构。进程1的这些结构的内容主要源自直接复制进程1,但是仍然需要进程1进行一些自我调整,包括但不限于根据栈上传参设置TSS中一些通用寄存器的状态等。

寻址机制重构

此外,进程1也需要构造属于他的映射方式,让它把自己进程中的线性地址映射到屋里地址中。这需要重新设置段基址,并重新构造一个属于他自己的页表,让他能独立进行对内存空间的映射和寻址。

首先需要注意,进程1的eip应该由进程0复制而来,和进程0处于同一个代码段,因此其逻辑地址中的偏移地址是不变的,但是进程1的段地址和进程0是不同的,这是为了区分页目录项(可以看第一大章中关于分页机制的描述),因此两个进程的线性地址是不同的。而进程1在刚生成的时候,按照预期,是执行进程0原有的代码,所以要确保能让不同的线性地址映射到相同的页框,需要把进程0的四个页表复制到进程1的四个页表的对应位置。

区分页目录项的方式,就是让每个进程的段描述符中的基址设置为nr*64MB,看着代码计算一下你会发现64MB正好对应页目录项跳过四个表项:64MB >> 12,除去页内偏移,是0x4000,再除以页表中的页框地址数1KB,即4,也就对应每个进程在页目录项中占据的4项。

一般来说是每个页表复制1024项,即整个页表。不过对于进程0的复制,因为进程0的代码量已知,复制时仅仅复制前四项即可。

关于LDT中描述符其他地方的重新设置的部分,没有太多难以理解的地方,也没什么重点,我就不再赘述了。

关于写时复制

fork出来的进程仍然涉及到与原进程共用某些内存的情况,如内核栈。因为寻址机制的复制重构并未让他们相关数据分离。如果涉及到数据写入,会引发冲突。linux内核采用写时复制的方式来解决这些问题,这个是后话了,暂且按下不表。

从内核态返回用户态并切换到进程1

从fork离开,fork会将子进程的pid返回。对于进程0,返回值为进程1的pid即1,对于进程1,返回值为0。这个返回值用以区分父子进程。

进程0在生不灭,它不会成为别的进程的子进程,它那“0”的位置也不会被任何进程取代。

离开用户态的时候会执行ret_from_sys_call这个系统调用,离开后进程0会执行pause。这里会涉及到进程的切换。进程的切换依据所有进程的状态(剩余时间片是否就绪是否可中断等)来判断要切换到哪个进程。将寄存器保存至TSS中,然后切换到对应进程执行。

ret_from_sys_call在当前进程有接收到信号时,会触发也会触发轮询并发生进程切换。

进程1:加载根文件系统

中断的重新建立让操作系统现在具备了和硬盘交互的能力,但是这里的交互是以硬盘盘块的形式,而非以文件形式。进程1接下来要做的就是把文件系统建立起来。这些工作依托于一个叫setup的系统调用完成。

硬盘相关数据设置

进入setup,先设置一些硬盘相关数据结构,如hd和hd_info。

hd_info用于硬盘逻辑块到具体位置的对应,而hd结构可以用于对硬盘引导块中数据进行设置。

读取硬盘引导块

这里用到了bread函数。第一个参数设备号为0x300+drive*5,代表硬盘。第二个参数0为逻辑块号,代表硬盘最开头的0号逻辑块。即硬盘引导块。

缓冲块查询

按照预期设计,硬盘和内存的交互需要通过缓冲区。所以braed需要先调用get_blk获得一个可以被使用的缓冲块。由于对应设备的对应块可能已经存在于缓冲区中了,所以要用哈希表进行一下查询,避免重复读取造成资源浪费。

如果这个硬盘逻辑块块现在没有从其他途径被读取到内存中,就需要在缓冲区中申请一个缓冲块,并调整缓冲块空闲链表。然后,调整缓冲块信息让其与设备号和块号对应。

缓冲块的申请逻辑详细阐述见后续文章。如果你在源码中难以理解加锁相关的东西,可以先行跳过。

获取请求项并与缓冲块挂接

调用ll_rw_block,让缓冲块和请求项挂接。

缓冲块的意义在于提供一个存放数据内存空间,而请求项更像是一个指令,提供了对设备进行的操作信息。

首先要对缓冲块对应的设备和块号是否存在进行检查。之后,调用make_request将缓冲块和请求项进行挂接。先尝试对缓冲块进行加锁操作,再根据操作的读或写类型,在全部或者2/3个请求项列表中,尝试获取一个空闲的请求项。然后将缓冲区和请求项正式挂接,并调用add_request函数将请求项挂接到请求队列当中。

加锁的代码中会涉及到关中断的行为,这里可能难以理解。在后续会有详细分析。

加载进请求队列

add_request会调用先前挂接过的do_hd_request函数去响应请求。根据读或者写操作类型,响应以不同的硬盘操作函数,之后会逐渐从函数退出,直到退出到bread函数中的wait_on_buffer,等待缓冲块读取完成。期间进程1会进入不可中断的等待状态,直到硬盘中断将其修改为就绪态。

之后进程会切换到进程0,进程切换的代码很好理解,这里不再赘述了。注意进程0的一个特点是如果当前没有任何按照约定可以切换到的进程,就会直接切换到进程0。另外,因为进程0不断进行pause,如果触发了进程0切换到进程0(在源码中实现为当前进程和待切换进程的nr相同),就会走一个较短的途径完成切换。

完成引导块读取

硬盘读取完成之后会触发硬盘中断,并调用end_request。将缓冲块相关标记位设置更新为可用状态,并将其解锁,然后讲进程1唤醒(通过wake_up将其设置为就绪态,但这里并不立刻执行进程1,不过机器马上会从进程0切换到进程1)

依据引导块中的信息,设置硬盘管理信息。随后调用brelse释放缓冲块。

读取软盘超级块

读取软盘的超级块,这个通过rd_load实现,与硬盘的读取类似。期间会使用breada函数从软盘中拷贝数据,这个函数和bread相似,但是一次可以拷贝三个数据块。

软盘中与根文件系统有关系的部分会被拷贝到虚拟盘,然后虚拟盘会被设置为根设备。

加载根文件系统

mount_root函数实现。这个函数会初始化设置很多关键的文件系统相关数据结构。

file_table

内核中的一个文件管理表。打开文件时会占用其中一项。

每个进程会维护一个指针数组filp,记录该进程打开的文件,这些指针就会指向内核空间的file_table。

获取超级块

初始化super_block(最多八项,即最多加载8个根文件系统),然后用read_super从虚拟盘中读取超级块,获得根文件系统信息。

read_super中使用了bread,但是因为设备这次变成了虚拟盘,最后do_rd_request并不会从硬盘上读取,也不会发生硬盘中断。

读取超级块信息后,会将其保存在super_block的空闲项中。

获取其它管理信息

依据备份的超级块信息,可以设置该设备的i节点位图管理结构s_imap和逻辑块位图管理结构s_zmap。并与超级块进行挂接。

随后,调用iget获得根目录的i节点信息。这会从内核中的i节点表inode_table中申请一个m_inode类型的空闲项用于保存。然后根据i节点位图和逻辑块位图,共同确定根目录项i节点对应的设备逻辑块号,然后讲该逻辑块读入缓冲区,并将根节点的i节点所在的逻辑块对应的缓冲区中的i节点内容读入对应m_inode结构中并保存。

然后讲s_isup和s_mount设置为这个i节点。完成根文件系统的加载。

打开终端文件tty0

打开文件的方式是open系统调用。关于这个系统调用,这里大致描述一下open系统调用是如何从文件路径确定到文件的i节点位置,并将设置内核和进程中的相关管理结构使其与该文件的管理结构挂接的。

确定文件的i节点

open系统调用会在inode_table中先找一个空闲的i节点,由open_namei完成这项工作,找到i节点后会将其保存在参数中传入的m_inode地址中。这个函数会调用dir_namei,而dir_namei内部会调用get_dir函数。

文件系统中,i节点代表了文件的信息,指出了文件的属性以及文件占用的数据块地址。而文件和文件之间的联系,依托于树形的目录结构。内核提供了一个find_entry函数,在给出目录项的i节点,目录下文件的名称(文件名字符串起始指针和长度)之后,这个函数可以从硬盘中读取数据并获得该目录下的某个文件的i节点的所属设备号逻辑块号,进而可以直接用iget读出来。

关于路径下某一个文件的名字析取,内核提供了get_fs_byte,可以用来配合进行字符串处理。

get_dir函数就是通过这个方式,从路径起点(根据根目录或者当前目录,这取决与open将要打开的路径名),逐步根据i节点信息读取目录内容获得目录下i节点的位置信息,再读入这个i节点,再往下不断索取,直到得到了目标文件的上一层目录的i节点。

注意,每次涉及到一个i节点,就要在相应的m_inode结构中增加一次对这个i节点的引用次数。这里包括路径中所有的i节点。这个原则不仅仅适用于这个函数,整个操作系统中所有长期使用一个i节点的行为都要做好这一点,同时当不再使用i节点的时候,也要对其引用次数减一。

然后退出到dir_namei,用同样的方式,读取到目标文件的i节点,然后返回到open_namei节点。

设置内核文件管理结构和进程文件指针

让tty0的i节点管理结构m_inode和file_table中的空闲项进行挂接。同时要初始化一些信息,包括文件属性、标志、引用次数、以及i节点读写指针等。

同时在进程1的filp中找一个空指针指向file_table表项。最后返回一个文件句柄(文件描述符),即其在filp数组中的下标。

由进程1“真正”变为进程2

一些善后工作

首先用两次dup系统调用,在进程1中复制两个文件句柄。这里的复制,仅仅是复制filp中的指针到另一空闲项并返回对应索引号。复制过程会增加对file_table中文件的引用次数,因此对应引用次数还要加一。

优先找filp中索引号较低的空闲项进行复制。

fork复制

fork的复制过程,先前在从进程0到进程1的过程已经讲过了。这里进程1通过fork复制得到进程2的过程也大致一致。只是,这里进程1创建了根文件系统,
并且打开了文件。因此文件1的filp数组也要被复制,file_table中表项的引用次数也要增加。

不过,由于进程1到现在为止,仍然没有直接对应可执行文件,它执行的仍然只是内核代码。所以进程1的可执行文件i节点仍然为空。这里也不会出现该i节点引用次数递增的情况

复制完成之后,进程1会执行waitpid,等待进程2退出。

从execve到shell

接下来会通过execve这个系统调用正式加载shell文件并执行。不过要注意,在此之前,进程2通过close这个系统调用关闭了0号文件描述符,并重新打开了一个文件/etc/rc。这个文件中保存了shell加载起来后要读入的命令。

shell会不断从0号文件描述符的文件中读取数据。

读取文件头

execve系统调用会执行do_execve这个函数。这个函数对于待执行文件有两种
处理方式,第一种是脚本文件,这时内核会以解析字符串的方式来分析这些命令,这里不再深究。另一种是加载可执行文件,这是我们要深究的地方。

execve函数的第一个参数是要执行的文件路径名。后面两个参数则和环境变量相关,这些信息会被保存起来。

首先既然要执行一个文件,就要先把这个文件读取进来,也就是获得这个文件的i节点。这里通过namei函数来实现。

然后do_execve要验证并设置一些权限相关的乱七八糟的信息,然后读取shell文件的文件头(文件头存于该i节点下属的第一个逻辑块,因此要把这个逻辑块读出来)

m_inode有一个zone[9]成员用于标记i节点占用的逻辑块。前七个zone直接保存对应的逻辑块号,第8个zone指向一个页框,内部存放多个逻辑块号。第8个zone则是采用二级逻辑块表方式进行管理。关于这些,后续章节会进行补充。

通过对文件头的分析,确定这个文件是一个可执行程序,同时要对代码文件长度进行一些检查。

管理结构:全部推倒重来

do_execve函数中有page[32]和p两个局部变量,它们是一个页面管理结构。接下来就要前面说的环境变量信息,通过copy_strings函数,把环境变量信息保存到主内存区的页面内,这些页面在copy_strings函数中申请而来,并被记录在page数组中。而p则记录页面中的偏移

copy_strings这段代码笔者没有打开看,估计就是用完一个页面就申请一个新的,直到填满吧……

接下来要调整进程2的很多管理结构。首先,进程2用iget加载了一个可执行文件并获得了其i节点,因此这个可执行文件的i节点对应的m_inode要被记录在task_struct中(一般情况下,还要接触和原可执行文件的关系,即对其使用iput)。同时,进程2的信号槽也需要情况,打开的文件也需要逐一关闭(依托于close_on_exec成员通过位图记录了所有进程2的源自进程1的需要关闭的文件描述符,逐个使用close进行关闭即可),最后close_one_exec也要清零。

我仔细翻了翻源码,其实并不是所有父进程打开的文件都会被关闭,这个是否关闭可以通过fcntl函数对于文件进行设置。在这里,进程2执行execve之前打开的/etc/rc是不会被关闭的

而旧的页表和页目录表也需要被销毁,用以解出掉原本和进程1完全相同的寻址和映射关系。所有占用的页面以及页表本身都需要被释放(引用次数减一)。经过处理之后,页表被清空,原有的寻址关系也随之失效。

接下来,调用change_ldt,重新设置局部描述符表中的描述符。第一是由于执行了新的可执行文件,很多段的段限长要重新设置。第二是先前有一个page结构,它管理的页面中保存了一些参数和环境变量,这些页面会作为进程执行新的程序时所持有的一部分物理页框。内核提供了一个高度集成的函数put_page,可以根据提供的在目标进程中预期的线性地址,快速将一个页框直接加入一个进程的页表。

真大方啊>_<

这些被大方地划归给进程2的页面中,还会保存由create_table创建的指针管理表,管理参数和环境变量。准确地说,这些页面绑定到的线性地址是数据段最高地址的一部分区域。而在这些参数和环境变量信息等加入页框最高地址处之后,页框内剩余的部分将作为用户栈空间。接下来用户栈会继续向页框低地址(起点)处生长,直到爆栈触发缺页中断。

加载shell

在调用execve的时候,将返回地址所在的栈地址压栈了。我们只需要根据这个栈地址,找到返回地址存放的位置,将shell程序的地址加载进去即可。之后eip就会变成我们填入的shell地址,然后会转而执行shell

注意shell程序未被读入内存,之前读入的只是文件头。这里会触发缺页中断,也就是下面要讲的部分。

进程2执行shell并处理缺页

缺页中断是因为页表里根据线性地址找不到对应页框。这会触发page_fault函数。这个函数有两个分支,一个是缺页错误do_no_page,也就是我们接下来要谈的。另一个分支是页写保护do_wp_page,在上面分析从进程0到进程1的fork时,我们有所提及。这里先来探讨缺页中断的问题。

缺页中断有两种情况,一种是可执行程序的页面没有加载进来,另一种是爆栈。判断方式是计算缺页的线性地址属于什么范围。这里的情况显然属于前者。

关于页面没有加载进来的情况,首先要判断有没有其他进程也在执行同样的程序且已经加载过这个页框,这里可以用share_page函数来进行判断。如果有,则直接把那个页面装载进来(但是会挂上页写保护)。如果没有,则考虑装载这个缺失的页面。

先申请一个空闲页面,然后调用bmap函数,获得缺失的页面对应的四个逻辑块号(试图读取四个逻辑块是为了正好凑齐一个页框),然后用bread_page把这四个逻辑块从虚拟盘中读出来,并加载到申请的页框中。当然,可能这段程序正好对应文件末尾,这四个逻辑块不是都属于和这个文件,这种情况就将尾部多余的数据清零。然后讲空闲页面通过put_page加载进页表。这样程序就可以先执行这个页中的程序了。如果再度缺页,则再度加载即可。

根据程序执行的局部性,应该可以有一个对于暂时不会执行的页框进行优化的处理,不过书里面并未提到,有空可以翻翻看看linus写在哪里了。

对于爆栈,处理方式类似。不过不需要涉及到文件的读取这种复杂操作了。

怠速

shell执行起来后,系统几乎就实现了怠速。但是注意这里进程2先前close了0,然后打开了一个文件/etc/rc。shell执行之后,会从0号文件描述符即这个设备文件里读取指令。这里的指令会做两个操作,一个是启动update进程,这个进程会定期将缓冲块中的数据向硬盘进行同步。另一个操作是把硬盘中的根文件系统挂在进超级块,以前的打开的文件都是存于软盘,这个操作会让硬盘中的数据也可以以文件的形式被操作。

我还是有点搞不清软盘这个上古之物究竟是干啥用的。

在完成以上操作之后,进程2会退出,并且发信号给进程1,进程1接受到信号后会被唤醒并离开waitpid函数,然后重新通过fork创建shell进程,shell进程这次会直接用终端tty0作为0号文件句柄,并执行shell程序,等待用户指令的输入。

至此,系统已经实现怠速,以上内容已经简笔勾勒出了linux 0.11的大致相貌。但是仍然有一些问题没有讲清楚,首先是硬盘中的文件系统需要挂载进来,这一点还没有涉足。其次是关于文件的操作还没有展开论述,文件的读写增删都很有学习的必要。另外,关于shell启动后创建用户态进程并执行程序的操作也需要细细分析一下,这里的问题包括但不限于进程之间的调度、先前提到的页写保护、以及多个进程进行文件读写时的场景分析。同时进程间通信这个令人头疼的问题至今也是只字未提。

3.挂载硬盘中的文件系统

shell从/etc/rc中读取的命令的第二句是mount /dev/hd1 /mnt ,其意义在于将hd1这个设备挂载到/mnt这个目录文件下。这个操作需要通过mount这个系统调用来实现。hd1就代表了硬盘设备。当然硬盘目前还只是以一个文件存在于文件系统中。现在,要将硬盘中的文件系统挂载到根文件系统中。

总结一下,这个系统调用在这里的操作大致是,读取/dev/hd1和/mnt的i节点。再通过hd1的i节点信息,调用read_super,读取hd1的超级块并将超级块挂载在super_block中,并根据读取的超级块获得hd1中的i节点位图和逻辑块位图。最后,将super_block中的s_imount设置为/mnt的i节点,将超级块挂载到根文件系统中,实现硬盘文件系统的加载。

4.文件操作

文件操作不外乎如下内容:打开,读取,写入,创建,删除。

因为笔者之前打CTF和这些系统调用接口打过不少交道,这里就仅仅分析原理,不再记录用法了。

本章所有分析都是限制于操作一般文件的范围,管道操作并不属于此范围,本章所有内容都不涉及管道。除此之外,一些权限管理的检查仅仅涉及某些比特位,属于“附庸”的结构,本章不再提及,详情请查阅原作和源码。

打开文件

打开文件的操作依托于open这个系统调用。在先前进程1打开tty0的时候有所介绍。

总结其过程,大致为:

  1. 设置参数并调用open系统调用,陷入内核态。
  2. 调用open_namei,获取文件i节点
  3. 将i节点挂接到内核的file_table表内
  4. 将对应的文件管理表项挂接到进程的filp指针数组内

关于寻找i节点这最为关键且复杂的过程,可以举一个例子,根据绝对地址,定位硬盘中的文件的i节点的过程如下:先解析路径名发现要从根目录开始,于是从根目录的超级块管理结构中,找到挂接的i节点(根目录一开始已经加入inode_table中了)。然后发现路径是mnt,根据根目录i节点信息和mnt这个目录下的文件名,搜索根目录下的目录项,确定i节点的位置(所处设备和逻辑块号),并以此读取mnt的i节点所在的逻辑块,接下来就会试图再次用iget获得mnt的i节点信息。

i节点由设备号和逻辑块号共同形成对i节点的一个唯一标识。每次读取一个i节点时,会在inode_table中检查这个i节点是不是已经被读了进来。如果读取了,就直接返回这个i节点,并令其引用加一。否则就将设备和逻辑块号记入i节点,然后再用read_inode从设备中读取i节点中的其他详细信息。

但是,这里mnt实际上是硬盘的挂载点,先前在挂载的时候,已经在inode_table中注册了mnt的i节点,且其i_mount节点标记为1,即这是硬盘的挂载点。于是iget会搜索超级块管理结构,找到记录了mnt的i节点为挂载点的硬盘超级块,并以此获得硬盘根目录的i节点,实现从虚拟盘到硬盘的转换。

之后的操作与先前讲解的open函数无异了。

读文件

通过read系统调用实现,其过程大致可以分为:

  1. 传参系统调用,陷入内核态。
  2. 根据fd找到内核中的file_table表项,获得i节点信息。
  3. 根据文件操作指针,确定要操作的数据对应的的设备号逻辑块号。
  4. 调用bread读取逻辑块
  5. 将读入的数据从缓冲块拷贝到进程的用户态空间。
  6. 循环读取,直到读完。

sys_read在一般文件读写的处理主要依托这个函数。这个函数需要为其提供i节点指针、文件管理项指针、要读入的用户空间地址以及要读取的字节数目。

因为读入数据很有可能要大于一个块,所以函数内用了一个循环,一次读入一个块,读完之后会让文件指针后移。

关键问题在于如何确定块号,这里使用bmap函数来处理,这个函数先前也提到过。先前也说过,文件保存的数据是通过zone[9]来管理的,而一个数据块的大小是1KB,当文件大小在7个数据块之内时,会用zone的前7项保存文件数据所在的数据块的逻辑块号。若文件大小大于7个数据块且小于512+7个数据块,则在zone前7项正常启用的前提下,启用zone的第8项。但是这里第8项即zone[7]内保存的数据块号对应的数据块内并不存放文件数据,而是存放512个数据块号(每个块号占据两个字节)。这512个数据块号对应剩下的文件数据块所在的位置。至于文件范围大于等于7+512个数据库且不超过7+512+512*512个数据库,则启用zone[8]即第九项。zone[8]会像zone[7]一样构建一个多级块号关系,不过zone[8]是二级块号表。

调用bmap时,需要通过文件指针f_pos确定要操作的位置是文件的第几个块,然后bmap会根据该块在文件中的相对逻辑位置,从zone中用一系列方式找到在硬盘中对应的逻辑块块号。然后就可以用bread读取这个逻辑块。

新建文件

sys_creat其实依托于sys_open实现。源码上,是sys_creat调用sys_open时传入特殊参数实现的。

在open_namei找到最后一级目录,准备找到目标文件i节点信息时,如果找不到符合条件的目录项,且open的flag参数带有O_CRAET,则会开始新建文件,需要做的事情如下:

  1. 为其在inode_table中分配一个i节点。
  2. 在对应设备超级块的i节点位图上,为其分配一个位置。
  3. 将i节点加载进上级目录中
  4. 将i节点登记进file_table
  5. 为用户返回文件句柄

但是这个文件现在是空的。想让它有一个属于它自己的具体的数据,需要进行写文件操作。

写文件与修改文件

写文件

write和read是一对,它们的接口和实现的源码看起来有很高的相似度。大致流程也差不多

  1. 传参系统调用,陷入内核态。
  2. 根据fd找到内核中的file_table表项,获得i节点信息。
  3. 根据文件操作指针,确定要操作的数据对应的的设备号逻辑块号。
  4. 调用bread读取逻辑块
  5. 将用户空间中的数据写入逻辑块。
  6. 循环操作,直到写完。

写文件的行为是截断的,从f_pos写入点写入之后的所有数据都会被舍弃。

定位数据块的时候使用的create_block函数,其实是对bmap的一个简单封装,但是这里write和read对bmap的传参不同,最后一项参数write传入的是1而不同于read传入的0。这是因为读取的数据块都是一定存在的,但是写数据可能会超出原有文件的长度,进而需要创建新的数据块。

新建数据块的操作有new_block函数负责。它先通过超级块的s_zmap即硬盘逻辑块位图,找到一个空闲的逻辑块。同时,因为new_block的操作的发生都是应用于写文件的场景,因此new_block会在内部get_blk调用为该数据块分配一个缓冲块。

出来后返回file_write,之后会用bread读取硬盘中的数据块。这里会因为相同的设备号和块号,而直接能得到上面分配的缓冲块,因此bread也不会进行任何读盘的操作。但是,如果上面是返回的一个文件所占据的数据块,那么bmap中就不会有创建缓冲块的行为,这里就需要从硬盘读取数据了。

修改文件

写入会截断文件,如果仅仅想修改文件的某一部分,就需要使用lseek,修改文件操作指针,将从修改点开始至文件尾部的所有数据都读入进用户空间,经过用户空间修改后,再用write写入文件中。

关闭文件

通过close实现:

  1. 清除filp表项,file_table引用次数-1。
  2. 若file_table表项中引用次数为0。则要释放其i节点(iput函数),将i节点引用次数-1。
  3. 若i节点不再被引用,则接触其与inode_table的关系。
  4. 解除i节点和inode_table后,要将i节点所对应的所有缓冲区中的文件数据同步更新到硬盘上。

删除文件

unlink系统调用:

  1. 获取i节点,判断权限(用户权限,是不是非目录文件)
  2. i节点链接数目检查(至少为1,不过这里对于0仅仅是警告)
  3. 链接数-1(通过路径名无法找到)
  4. 当连接数减至零时,清空在硬盘上对应的i节点位图和逻辑块位图
  5. 清空内核中对应的i节点表项,清空上级目录下对应的目录项

感觉有点乱,回头再学学

文件的同步

修改过的文件不会立刻将缓冲区的数据同步硬盘,同步行为会在以下时间触发

  1. 由shell创建的update进程负责
  2. 主动调用sync_dev

5.进程内存管理

这一章大多都是在重复第二章讲过的东西,如果第二章看的比较仔细且经常抛出疑问,应该基本都学的差不多了。这里就只记录一些第二章没有详细讲的问题吧。

关于“栈”

栈是一个能动态增长的数据结构。它非常灵活,换句话说,就是说它不是那么确定,有爆掉的风险。

还有一个东西叫堆,不过我对内核堆这个词已经有点pstd了。

用户态的程序需要栈,但是进程陷入内核态时也需要栈。这两个栈显然是需要隔离的开的。

创建进程的时候会申请一个页面,从低地址开始存放进程的管理结构。而该进程的内核栈就放置在了这个内核高地址的位置。这可以从fork调用的copy_process函数的关于tss的ss0和esp0的设置可以看到,这两个寄存器是专门设置内核栈的。ss0用的是GDT中存放的内核态专用的数据段,而eip则被设置为页面最高地址处。之后内核栈会向低地址方向,即存放着进程管理结构的方向生长。这种安排看似存在危险,但是由于内核代码是我们可控的,我们可以对于内核栈的生长范围进行预估。内核设计者精确估算了内核栈的生长,确定把内核栈放在这个位置,不会让内核栈的生长破坏进程的管理结构。

用户栈则放在了execve时保存参数和环境变量的那个页面,最高地址处是参数和环境变量,而用户栈则放在了页面剩余空间的最高地址处。这种安排是没什么问题的,如果栈爆了就出发缺页中断就行了。而fork之后execve之前,子进程的用户栈和父进程共享同一块栈空间,但是这个空间被挂上了页写保护。一旦有写栈操作,就会触发页面的复制,将两个进程的栈空间分离开来。

页写保护

父进程将自己的页面映射关系拷贝给子进程的时候,会对所有的页面加上一个页写保护(追溯到代码是this_page &= ~2)。缺页中断会在第二位为0的页面进行写操作时触发,最终会确认为触发了页写保护机制。然后会跳转到do_wp_page去处理,最终会执行un_wp_page来处理。

处理的思路很简单,先在mem_map中把这个页的引用次数减一(因为一个页表的映射要与其脱离了)。然后申请一个空闲页,将新页框地址填入页表,最后进行页框内容的复制。

这时,如果mem_map已经减至1,当剩下的那个进程触发页写保护时,会发现已经没有进程与之共享页面了,这时将第二位置位,撤销页写保护即可。

进程退出

进程的退出依赖于exit系统调用实现

  1. 释放掉页表(引用计数-1)
  2. 向父进程发送信号
  3. 关闭所有文件(全部close)
  4. 释放掉current->root,current->pwd,current->executable(对对应i节点用iput)
  5. 触发进程调度

6.多个进程操作文件

其实这一章讲的主要是讲一些实际例子。关于读盘涉及到的数据结构本身已经没有太多知识点要写了(多个进程无非是引用次数加一减一的问题)。唯一需要理一理的是缓冲块和请求项的加锁问题,我觉得这是本书的最难点。

多个进程打开文件

需要注意,多个进程打开同一个文件,会在file_table创建多个管理项,因为每个进程对于同一个文件可能会有位置不同的文件操作指针。

缓冲块的上锁与等待队列

bread在通过get_blk获得缓冲块后,会调用ll_rw_block将缓冲块和请求项挂接并根据请求触发读盘。这个操作相当于该进程会占据这个缓冲块,因此要给缓冲块上锁,这其中会用到lock_buffer函数。

之后读盘时,进程(假如说编号为进程1)要等待读盘,会执行wait_on_buffer。因为缓冲块已经上锁了,所以会调用sleep_on函数。sleep_on传入的是缓冲块等待队列指针的地址,sleep_on会将当前进程的task_struct指针挂载进这个单向链表结构的等待队列的头部。然后触发进程调度把切走到别的进程。

假如说此时又有一个进程2申请这个缓冲块,首先,申请的过程是直接从哈希表里拿到这个缓冲块。然后在试图对这个缓冲块使用lock_buffer进行上锁时,因为这个缓冲块已经上锁了,所以需要等待上一个进程解锁。于是它也通过sleep_on,将自己加入等待队列等待缓冲块解锁。同时切到其他进程。

此时,如果读盘完成,会触发硬盘中断,首先陷入内核态,然后硬盘中断服务会调用end_request,使用unlock_buffer解锁,并且唤醒等待队列头部的进程(仅仅是变成就绪,不是立刻切换)。之后很快会切到进程2,进程2回到sleep_on函数末尾,让之前等在他后面的进程(进程1)被唤醒。旋即进程2给缓冲块上锁并进行读盘,并把自己再次挂进等待队列。

这时,进程1已经读完了,可以从缓冲块中读取数据了。

再次读盘完成时,进程2会再度被唤醒。

这段写完我自己都觉得乱,我个人感觉不如看书,但是书上说的似乎也有点毛病?所以我更想精炼地讲一下我自己啃源码所理解的设计的原理

真要说梳理一下,就是进程会因为等待缓冲块解锁或者等待硬盘读取完成这两种情况而调用sleep_on进行等待,而sleep_on在从当前进程切出去之前,会记录下来缓冲块等待队列最靠近头部的那个进程的task_struct指针,然后把自己插入到缓冲块的等待队列指针(相当于插入到了等待队列的头部,典型的FILO)。之后才会设置自身为不可中断的等待状态并触发进程调度。

这种不可中断的等待,仅仅能通过硬盘中断唤醒。具体的唤醒细节是,硬盘中断会调用end_request,先给缓冲块解锁,然后找到这个缓冲区等待队列头部的进程,通过wake_up让这个进程变为就绪态。当当前进程时间片耗尽时,就能切到刚被唤醒的进程,继续执行sleep_on最后的部分。sleep_on则会将先前保存的等待队列最靠近头部的task_struct指针对应的进程唤醒。

通过上述描述,我们会发现,最先加锁造成等待队列中进程堆积的,是请求读盘操作的进程1。因为这个进程是第一个给缓冲加锁的进程,他在加锁占用进进程的时候不需要等待。但是,他在读盘的时候明明是占据第一个位置,但是后来试图给缓冲块加锁占用的进程2插队把它挤到了后面。本来在缓冲块直接指向的进程被挤到了栈里面。

而读盘完成时,唤醒的是进程2,而非引发读盘的进程1,而进程1实际上是由就绪态转为执行状态的进程2唤醒的,而进程2之所以等待,不是因为读盘,而是因为进程1等待读盘占用了缓冲,导致进程2没能给缓冲块上锁,所以进程2接下来还需要再请求读盘,然后两个进程又要等下去,这是这个机制最搞笑的一点。接下来进程2会把自己挂进等待队列,然后切换到进程1。进程1抢到CPU之后,本来准备爽读一波缓冲区的数据,然后发现我***你个**进程2你**明明来的比老子晚怎么敢给先老子一步把缓冲块锁上的????(shell进程表示有被冒犯到)(笔者写到这已经精神不正常了>_<)。但是没有办法,毕竟操作系统就这样安排的,就只能把自己挂进等待队列,把进程2给挤下去。等再次读盘完成,就唤醒进程1,进程1再唤醒进程2,然后进程1就可以正常读盘了。之后进程2也可以正常完成读盘。

其实每次唤醒,都是只把一个进程推进了一步,要么成功读盘,要么成功上锁。而等待队列的关系通过保存上一个等待进程在栈上,也可以实现回溯。(不过感觉还是哪里不太对,晕晕乎乎的,回头再想想吧)。

sleep_on调用前后会出现关中断和开中断,这是因为缓冲的解锁是由硬盘中断触发的。如果在硬盘中断发生于通过while循环的条件之后、将自己挂进等待队列并切出之前,那么就有可能会因为让进程因为一个没有被锁住缓冲块而休眠,也就大概率不会有一个与之对应的(刚刚错过了的)硬盘中断将它唤醒了。虽然关了中断,但是切换进程后,通过eflags寄存器的恢复,新进程的中断又被打开了。所以依然可以接收硬盘中断。

缓冲与外设的同步

其实这一节是书上的6.2,但是我不太知道该记录啥,到这里已经学的都差不多了,就随便写写吧。

能将数据写入一个缓冲区的条件是:该缓冲区空闲,且该缓冲区不为脏。脏标记会在这个缓冲块被写入数据后被置位,同时它也会在同步进硬盘后被清空。

书上说空闲是b_count为0,但是我看完源码后觉得应该是缓冲块未被上锁,感觉不大对?

缓冲块的便利性分为四个等级:0-3,加锁为1,脏块为2,而缓冲块的便利性便为加锁和脏的情况之和,缓冲块便利性计算结果越小越会被视为容易利用。

因为加锁的块一般是正在读写,而脏块需要同步,显然脏块处理更麻烦。

get_blk从空闲链表返回的缓冲块,是这四个等级中最便利的一个,这也就意味着有可能刚申请来的缓冲块,即便是从空闲链表拿到的,也有可能不能立刻用。需要等待解锁或等待同步。

当缓冲区所有的块都是脏块的时候(申请到的最便利的块是脏块),会由sync_dev触发同步,脏块的数据会被更新到硬盘内,同时脏标记会被清空。sync_dev会遍历整个缓冲区,如果硬盘被占用了,就会将请求项挂载进请求队列当中,当硬盘中断触发时,会执行新请求项。

和缓冲块类似,请求项也有可能被占满而让进程发生等待并切换到其他进程。对于请求项的等待会专门挂载在一个全局的等待队列指针内。

这个等待也是用sleep_on实现的,也就是说也会出现插队现象。

7.IPC问题

管道

感觉管道没啥好说的

pipe系统调用,会生成两个文件描述符(filp占据两项),但file_table只占据一项。

为pipe创建i节点的时候,引用次数会被设置为2(pipe默认有且只能有两个进程可以操作),同时要给i节点的i_pipe标记置1,表明这是一个管道文件,即只是一个内存页面,并不是硬盘中的文件。

读写管道也是依托于read和write系统调用,内核中针对管道文件有专门的处理分支。如果出现管道为空无法读出,或者管道堵塞无法写入的时候,会让当前进程陷入睡眠状态(挂进等待队列),等待数据写入管道中,或者等待管道中数据被读出从而腾出写入空间。同时读写管道的行为有一个特性:每次写入数据或者读出数据,就会将读/写管道进程唤醒。

信号

感觉信号也没什么好说的

每个进程的task_struct有一个signal成员,作为一个信号位图,标记进程收到的信号。还有一个sigaction[32],保存信号处理函数指针。signal系统调用可以给进程挂载信号处理函数。kill系统调用可以依据pid给某个进程发送某种信号,内部会调用send_sig设置目标进程的signal信号位图。

当一个进程收到信号时,它会在下一次进程调度时被设置为就绪态。然后在调度函数的就绪态检测的遍历中,这个进程会被选中并执行。

进程从就绪态被调度转而执行时,会经历从内核态切换回到用户态的过程。这时会触发信号处理。会将信号处理函数压栈,先执行信号处理函数,再回到断点执行。

注意,如果进程之前处于不可中断的等待状态(如之前出现了读盘),则不能被信号唤醒。信号要在从不可中断状态恢复时(如被硬盘中断唤醒),才能够处理信号。

8.other……

写完了,不过还是要在这里挖个坑,没准会回来再写写总结啥的

–EOF–

(2024.11.6)


《linux内核设计的艺术》学习笔记
http://example.com/2024/10/29/Blog/IIE/Basic/linux内核设计的艺术——学习笔记/
作者
Jmp.Cliff
发布于
2024年10月29日
许可协议