前言
前面已经分析了XV6的启动流程以及内存管理,接下来,我们探究进程调度的实现。与其说进程调度,我觉得可以顺应内存的虚拟化的叫法,将进程调度称为“CPU的虚拟化”更加贴切。
首先明确目前XV6的cpu的状态如下:关中断 & 进入了保护模式 & 安装了包括【内核&用户】【代码&数据】段描述符 & BSP分配了一个4K的内核栈 & 开启了以kmap为基础的粒度为4K的内核分页。
Onix相关链接:
XV6-x86的github链接:
- 链接。
中断机制
对于中断的部分,这里会涉及大量硬件相关的知识,由于博主的目的是了解OS的基本框架,所以硬件相关的知识储备可能不会太深,如果你是想弄清某个硬件具体实现,这篇博客可能不适合你。
这里总结一下我对中断的理解:
引发中断的方式有三种:外中断、异常、软中断。
外中断:就是由外部中断控制器通知 CPU 某个事件完成了,比如:磁盘寻道完成可以进行读写了、UART输入寄存器非空(可读)、UART输出寄存器为空(可写)、键盘缓冲有数据了(可读)等等。
异常是 CPU 在执行过程中,因为出错而执行不下去了,比如:除零异常、因为虚拟页面还没映射发生缺页异常、对只读段进行写操作触发段错误异常等等。
软中断,可以认为是应用程序和操作系统沟通的一种方式,运行在低优先级程序想要对硬件做IO,但是由于只有处于特权级的内核能够直接和设备打交道,从而低优先级程序必须通过某种机制来完成特权级转换,这种机制就是软中断。我们也可以将实现这种功能的函数称为系统调用。
如有些教科书那样,我们也可以把异常和软中断统称为 内中断,也就是这个中断时 CPU 和 软件内部产生的,与外部硬件无关。
Onix单核处理器的中断原理
单核PC机上,一般会采用(主从)两片 8259a PIC(programmable interrupt controller),将PIC的INT引脚接到CPU的一个引脚上,如下图,图片引用自onix的文档,如有侵权,可告知删除:
从图中可以看到有两个8259a,上面那个8259a是主PIC,它的INT引脚直接接到CPU上;下面那个8259a的INT引脚接到主PIC的IR2引脚,所以它是从PIC。 每一个PIC的引脚会接一个外设,(如果对应的引脚没被屏蔽的话)外设会通过PIC间接向CPU发中断。
在PIC正式工作前,需要对其进行一系列初始化。初始化操作由cpu发送一系列的控制字完成。有两类控制字:
初始化命令字 (Initialization Command Words, ICW), ICW 共 4 个, ICW1 ~ ICW4;
操作命令字 (Operation Command Word, OCW), OCW 共 3 个, OCW1 ~ OCW3;
ICW 做初始化,用来确定是否需要级联,设置起始中断向量号,设置中断结束模式。因为某些设置之间是具有依赖性的,也许后面的某个设置会依赖前面某个 ICW 写入的设置,所以这部分要求严格的顺序,必须依次写入 ICW1、ICW2、ICW3、ICW4;
OCW 来操作控制 8259A,中断的屏蔽和中断处理结束就是通过往 8259A 端口发送 OCW 实现的。OCW 的发送顺序不固定,3 个之中先发送哪个都可以。
具体细节非常推荐读者去阅读一下Onix文档,讲的真的很细致:https://github.com/StevenBaby/onix/blob/dev/docs/04%20%E4%B8%AD%E6%96%AD%E5%92%8C%E6%97%B6%E9%92%9F/033%20%E5%A4%96%E4%B8%AD%E6%96%AD%E6%8E%A7%E5%88%B6%E5%99%A8.md
8259a中断控制器的初始化就是一种固定套路,截取Onix代码如下:
1 |
|
CPU在处理每个外中断后,需要向PIC发生一个结束字为的是通知PIC中断处理结束,具体中断结束方式由OCW2 来设置。代码如下:
1 | // 通知中断控制器,中断处理结束 |
至此单核OS中断的初始化到这里就结束了。
XV6多核处理的中断原理
多核处理器中断控制器的结构更为复杂,因为偏向硬件,这里就只记录一下我对APIC的理解,理解不会太深,如果有错误,非常欢迎读者纠正!
首先还是供上架构框图:
图片截取自:https://pdos.csail.mit.edu/6.828/2014/readings/ia32/MPspec.pdf
注释:
BSP:bootstrap processor,可以简单理解为主处理器。
APx:application processors ,可以简单理解为从处理器。
关于BSP和APx的关系这里先埋个伏笔。在【AP(从)处理器的启动】段落会进行详细叙述。
从图中的上半部分可以了解到,每个CPU各自接着一个Local APIC(Advanced Programmable Interrupt Controller)。注意,每个lapic和cpu是封装在一起的(这里并不严谨,是否和cpu封装在一起其实和cpu的架构有关)。后面我们会看到,不止APIC,在多核cpu上,它的每一个cpu都有自己的一套cpu寄存器,比如:esp、eip、eflag等等。具体情况我们在“进程调度”段落进行详细讲解。
从图的下半部分,我们可以了解到所有的lapic都接到了ICC(interrupt controller communications) Bus上,并且,总线上还接了一个IO APIC,这里的ioapic是和cpu分离的,它被接在cpu的外部。ioapic会接收来自各个外设的中断。然后对各个外设发来的中断做一些判断和处理,再将中断的IRQ号和lapic的ID封装在一条“报文”中分发給对应的lapic,具体发给哪些lapic,我们可以通过配置ioapic来进行设置。ioapic左边其实是有很多引脚(实际16个,如果级联了8259a可能会更多)每个引脚都可以接外设。有趣的是,从图中ioapic的左边可以看到,ioapic的引脚还可以接8259a PIC控制器,这非常完美的兼容了单核cpu的中断控制器的架构。
iopic是依据重定向表项 RTE(Redirection Table Entry)来构建“报文“,RTE对每一个中断都会有一项64位的entry。通过entry,可以单独设置ioapic在收到中断后对中断的操作。每一项entry描述:中断对应的中断向量?中断有没有使能?中断传输状态?发给哪个lapic?
每个lapic都有一个唯一的ID,cpu可以在特定的内存(device space)上来查询自己所对应的lapic的ID号,lapic的ID其实也唯一标识了一个cpu。lapic会根据自己的ID从ICC Bus上接收属于自己的中断”报文“,然后经过一系列检查最后将中断发给cpu,当cpu处理完中断后,会反馈给自己的lapic,lapic收到cpu的回复后,同样将中断处理完毕的消息通知给ioapic,这点和单核架构中,cpu处理完中断后向master pic发送PIC_EOI是一样的道理。
特别的是,lapic也可以像ioapic那样作为中断“源”(这里可能不严谨,但是可以类比去理解),向其他的lapic发送中断“报文”,这是通过ICR(Interrupt Command Register)寄存器实现,ICR的结构和ioapic的RTE表的entry结构类似,也有中断向量号、lapic的ID等字段。lapic主动向其他lapic发送中断“报文”最常见的场景就是BSP去启动其他APs,这一般通过会发送INIT or STARTUP IPI(interprocessor interrupts)。
由于XV6中cpu对lapic、iopic初始化代码上,依赖于mpinit函数,而mpinit和多处理器内容相关,所以lapic、iopic初始化我们放到”AP(从)处理器的启动”段落进行讨论。
关于Local APIC和IO APIIC详细内容可以参考博客:https://blog.csdn.net/weixin_46645613/article/details/119207945
中断描述符表
之前一直在介绍中断相关的外设,接下来我们看看cpu内部是怎么利用寄存器来定义中断的。
因为中断不止一个,所以,和全局描述符类表似,中断表也是通过一个大的数组来记录每一个中断的属性。数组中每一个Entry格式如下图,每一个Entry同样是8个字节:
Offset:记录中断门或陷阱门的处理函数的地址。
Selector:处理函数的段选择子。
Type:标记是中断门还是陷阱门。 注意:中断门会自动清除eflag寄存器的FL_IF标志位,而陷阱门则保留eflag的FL_IF标志位。 也即中断门会i自动关(外)中断,而陷阱门则不会有关中断的操作!
S:必须为0。
DPL:描述符可以被哪个特权级使用。对于中断门一般是0x0,对于陷阱门就是0x3(DPL_USER)。
P:是否有效,固定填1.
XV6相关代码注释写的非常好,上面的中文注释也是参考XV6的注释写的,如下:
1 | // Gate descriptors for interrupts and traps |
中断描述符寄存器如下:
高32位存放中断描述符表的基地址,低16位存放中断描述符表的大小(字节为单位)。
AP(从)处理器的启动
首先还是回归main函数:
1 | // Bootstrap processor starts running C code here. |
接下我们要从BSP执行的maiin函数开始,深入分析以上代码的作用。
mpinit:探测各个cpu
该部分主要参考:多处理器规范,因为我英语也是很菜,所以硬着头皮捡重点去看了一部分。
这里对多核处理器的启动流程做一个简单总结:我们可以理解为,多核CPU中,有一个CPU被设计成BSP,其他的CPU都被设计成AP。当然,在实际硬件设计上为了考虑容错性,任何一个CPU都能成为BSP核。 系统最开始,BSP有对硬件的绝对控制权,包括去控制其他AP的启动和停止。为了启动其他AP核,BSP首先通过三种可能的方式搜索MP floating pointer structure,如果找到了一个有效的MP floating pointer structure就去遍历MP configuration table查询处理器信息和ioapic的信息;如果无法找到一个有效MP floating pointer structure,那就认为系统只有一个CPU——BSP。在所有CPU启动后,BSP就退化成AP,系统不存在BSP、AP之分。 当然,我们需要要记录BSP CPU的lapic的ID(这个ID也唯一标识着CPU),这样我们才知道谁可以去其控制其他CPU的停止。在BSP启动其他AP前,因为AP CPU是暂停状态,所以其他AP无法执行OS代码,并且大部分中断都是被禁用,但是INIT or STARTUP interprocessor interrupts (IPIs)不会被屏蔽,当AP收到来自BSP的INIT or STARTUP中断,就会启动它自己。 AP在收到BSP的启动中断后,也会进入保护模式、有自己的独立的一套寄存器、设置自己的全局描述符、开启分页、有自己的堆栈等。
首先BSP会通过三种方式去搜索MP floating pointer structure,三种搜索范围都在1M以内,因为MP floating pointer structure就是由BIOS提供,而BISO寻址范围就1M:
- In the first kilobyte of Extended BIOS Data Area (EBDA), or
- Within the last kilobyte of system base memory, or
- In the BIOS ROM address space between 0F0000h and 0FFFFFh.
低1M内存的内存映射参考:https://wiki.osdev.org/BDA#BIOS_Data_Area_.28BDA.29
MP Configuration Data Structures整体框架如下图,图解了MP floating pointer structure、MP Configuration Table Header、Table Entries三者之间的一个关系,先了解一下大致的框架,接下来我门逐一剖析。
MP floating pointer structure图解如下:
主要关注它的PHYSICAL ADDRESS POINTER,它指向MP config table的物理地址。
MP Configuration Table Header结构如下:
主要关注几个字段:
MEMORY-MAPPED ADDRESS OF LOCAL APIC:描述 cpu(每个CPU都将它的lapic映射到了同一个物理地址)的lapic的寄存器物理地址。注意这里是”每个cpu”,虽然是同一个物理地址,但是在每一个cpu去读的时候,分别映射到了各自的lapic的寄存器地址上了。
BASE TABLE LENGTH:整个table的长度,虽然存在扩展表长度,但是我们还用不到。
MP Configuration Table Header后面会跟上各自类型的Base MP Configuration Table Entries,每个Entry的第一个字节会标明其类型,并且每种Entry的长度都各自固定,所以我们可以通过一个循环来遍历每个Entry,一共有5种类型的Entry,如下图:
XV6中我们主要关注Processor Entries和I/O APIC两种类型的Entry。
Processor Entries结构如下:
主要关注LOCAL APIC ID,如该字段名字那样,就是代表和CPU绑定的lapic的ID,通过它我们也可以唯一标识一个CPU。
I/O APIC Entries结构如下:
主要关注I/O APIC ID,代表I/O APIC的ID。
然后上代码:
1 | void |
mp->imcrp字段的解释如下:
详细信息可以了解一下:多处理器规范
lapicinit:BSP初始化自己cpu的lapic
这部分和硬件强相关,我也了解不是特别多,尽可能的讲清楚吧。硬件相关的初始化深入下去也是一个无底洞。如果读者感兴趣的话,可以去查intel 64 and IA-32 卷3开发手册。
1 | void |
以上代码就是初始化BSP的lapic,在其他AP启动后,都要执行一遍这段代码。
ioapicinit:初始化ioapic
ioapic的作用和单核架构下master pic很像,但是对于ioapic的初始化步骤很简单,不需要发送一系列的控制字。对于ioapic的初始化就是简单的配置一下重定向表项 RTE(Redirection Table Entry),给RTE的每一项一个初值,设置它的中断向量号(起始T_IRQ0,T_IRQ0 == 0x20),并且默认是中断屏蔽的。后续需要什么中断再对相应的Entry做配置即可,比如consoleinit为了使用键盘调用了ioapicenable去配置对应的Entry打开中断等。关于重定向表项 RTE(Redirection Table Entry)的解释,读者可以看一下这篇文章,讲的非常详细:https://blog.csdn.net/weixin_46645613/article/details/119207945
1 | void |
tvinit:初始化中断向量表
这里自顶向下介绍XV6的中断向量表是如何构造的。
涉及到的变量如下:
1 | // Interrupt descriptor table (shared by all CPUs). |
首先是tvinit函数,它是最顶层负责构造中断向量表的函数,SETGATE宏在上面已经贴过它的实现,这里简单介绍一个各个参数的作用。
SETGATE(gate, istrap, sel, off, d) :
参数1:对应idt[i],表示每一项entry。
参数2:标记是中断门还是陷阱门。
参数3:段选择子。
参数4:中断处理函数地址。
参数5:中断描述符被哪个特权级使用。
结合代码来看:
1 | void |
然后是vectors.pl文件生成汇编代码的过程,pl我之前也没有了解过,不过从它的代码可以看出,有点像字符串拼接的处理语言,简化了重复性代码的编写,代码如下:
1 | #!/usr/bin/perl -w |
从pl代码上我们可以看到,就是利用for循环构造vectors数组,该数组专门存放中断处理函数。我们先来分析一下它如何构造vectors的,首先最上面有一个for循环,for循环中使用了一个if判断,因为有些中断cpu不会自动压入错误码,所以我们需要手动压入一个占位值,方便trapret的处理。在for循环下面最后压入了一个jmp指令,所以pl生成的汇编并不是中断处理函数最终代码,pl生成的中断处理函数会跳到alltraps,alltraps代码我们下面再进行分析。pl在最后生成的汇编代码定义了一个vectors数组,数组里面元素就是上面定义的256个vectori(i=1、2、…)。
最后就是trapasm.S文件对alltraps的实现,常规的进行上下文保护:
1 | #include "mmu.h" |
tvinit、pl、alltraps三者之间的关系总览图如下:
关于中断帧,这里要注意,涉及特权级转换的中断帧和不涉及特权级转换的中断帧有些许不一样。如下:
用户态触发中断(陷阱门或中断门)过程如下:
用户查询TSS(任务状态段)段,找到用户进程在内核态的栈段和栈顶指针(ss0、esp0)。
cpu将ss、esp压入(内核)栈中。(硬件
cpu将eflags、cs、eip压入栈中。中断门还要关中断,陷阱门不用。(硬件
执行用户中断处理函数alltraps的上下文保护的代码。(软件
调用trap函数,处理各种中断。
执行用户中断处理函数trapret的上下文恢复的代码。(软件
调用iret,cpu恢复eflags、cs、eip。(硬件
cpu恢复ss、esp。(硬件
而内核线程发生中断(注意,内核态不会发生系统调用,这不应该也不合理),过程如下:
cpu将eflags、cs、eip压入栈中。中断门还要关中断,陷阱门不用。(硬件
执行用户中断处理函数alltraps的上下文保护的代码。(软件
调用trap函数,处理各种中断。
执行用户中断处理函数trapret的上下文恢复的代码。(软件
调用iret,cpu恢复eflags、cs、eip。(硬件
XV6中定义的栈帧结构体如下:
1 | //PAGEBREAK: 36 |
中断帧如下图:
图片引用自:https://pdos.csail.mit.edu/6.828/2018/xv6/book-rev10.pdf
startothers:激活其他AP处理器
到这里终于要开始启动其他AP核了,AP核的启动也是一种固定套路,在多处理器规范中这种套路称为universal algorithm。XV6中这个算法实现在lapicstartap函数中。
流程如下:
XV6代码启动其他AP处理器的核心流程如下:
1 | // Start the non-boot (AP) processors. |
在每个AP boot点和BSP的boot点类似,BSP是在0x7c00启动,AP是在0x7000启动。同样,0x7000也会执行一段汇编代码,这段汇编代码作用就是bootasm.S + entry.S代码的结合体。这里简单总结一下:
加载临时全局描述符,进入保护模式
使用entrypgdir开启分页。
切换到预分配的内核(scheduler)栈。
进入mpenter。
考虑到文章太长,代码就不放了。文件是entryother.S,有兴趣的读者可自行研究。
mpenter代码如下:
1 | // Other CPUs jump here from entryother.S. |
上面那段代码就是:AP会像BSP那样,调用一遍所有的和CPU相关初始化函数,最终进入mpmain。(BSP在main最后也会进入mpmain,前面提到过BSP启动其他后,也成为了一个AP)
mpmain在加载中断描述符表后,最终就会进入scheduler,CPU正式开启操作系统的任务调度!
mpmain如下:
1 | // Common CPU setup code. |
进程调度
首先了解一下XV6对CPU的定义,注释写的非常详细:
1 | // Per-CPU state |
下面列出了各个CPU资源的共享情况,可以做一个参考:
资源 | 共享 | 不共享 |
---|---|---|
中断描述符表 | √ | |
lapic(也指外中断,包括定时器等) | √ | |
ioapic | √ | |
cpu的各种寄存器,包括eip、esp、eflag等等 | √ | |
全局描述符表(包括任务状态段) | √ | |
kpgdir(内核调度器使用的页表) | √ | |
物理内存 | √ | |
任务队列(ptable) | √ | |
调度器的执行栈 | √ | |
外设 | √ |
接下来是XV6的PCB(进程控制块),之前老是在教科书上看到它,当时感觉很难理解,在分析过OS源码后,再回过头去看,就感觉特别通透。XV6的PCB就是一个结构体,里面存放了很多成员,XV6的PCB和进程的内核栈是分开的,PCB结构体是通过一个指针来指向进程的内核栈。相比之下,Onix的PCB和内核栈是连在一起的,内核栈的低地址就是存放的PDB结构体,因为esp是线下增长,所以esp指向高地址处,并且典型的内核栈大小是一页(4K)(于是esp指向页面的4K处)。
XV6的PCB定义如下:
1 | // proc.h |
XV6为进程定义了6种状态:UNUSED(PCB未使用), EMBRYO(初始化中), SLEEPING(阻塞休眠), RUNNABLE(可调度), RUNNING(运行中), ZOMBIE(僵尸/待回收)。 这里特别说明一下PCB的chan成员,该成员一个进程的等待条件。XV6中一个进程可能会调用sleep、wait系統調用,或者在調用read系統調用时间接调用了sleeplock,这些函数都会使一个进程进入阻塞状态,XV6的阻塞状态统一使用SLEEPING来表示,阻塞就是为了等待某个条件发生,当等待的条件发生时,阻塞的进程就会被唤醒,但是ptable有那么多阻塞的进程,我应该唤醒ptable中的哪些进程呢?此时chan就起到关键作用,在进程进入阻塞之前,会将chan设置为某一个变量的地址,当条件满足XV6就是通过chan来唤醒对应的进程的,当然这个变量的选取是很有讲究的,比如在XV6中因为sleep而休眠的进程,它的chan会被设置成ticks(作用类似jefrris,定时器中断的计数器)的地址。具体细节就不深入讨论,感兴趣的读者可以看看XV6的源码。
从AP(从)处理器的启动段落我们知道,BSP、AP最终都进入scheduler函数,铺垫了这么久,scheduler函数也是本文的主题,那么先来看看它的代码实现吧:
1 | //PAGEBREAK: 42 |
XV6的调度算法非常简单,就是简单的round robin算法。主要精华是整个调度的过程,至于它具体的调度算法其实显得并不是特别重要。
switchuvm函数实现非常关键,它里面会设置tss,并且设置cpu使用进程p的页表。tss全称是任务状态段,它可以帮助处于用户态的进程回到内核态,因为一个进程有两个栈,一个是出于用户态使用,另外一个是处于内核态使用,进程从内核态转变成用户态容易。只需要将中断帧弹出恢复上下文即可,但是从用户态回到内核态就难了,因为进入用户态后,进程的用户态空间不会保留进程任何内核态信息,所以,我们需要一个东西来帮助处于用户态的进程在需要陷入内核态时,找到它的内核态的栈,这个东西就是TSS,TSS会记录一个进程的内核栈的栈指针esp和栈段ss,switchuvm函数正是完成了这样的功能。实现如下:
1 | // Switch TSS and h/w page table to correspond to process p. |
swtch函数由汇编实现,是进程切换的核心函数,实现如下:
1 | # Context switch |
struct context结构体定义如下:
1 | struct context { |
一个进程在(因为时间片用完)需要放弃cpu执行权限,如何回到scheduler呢?答案就是使用sched函数,本文我们以普遍的事件来分析————因为时间片用完而放弃的cpu。处于用户态的进程因为时间片用完会发生定时器中断,定时器中断又会引发从用户态到内核态的切栈、保存上下文、执行trap函数,trap函数中最后调用了yield,yield最终会调用sched,trap函数伪代码如下:
1 | struct spinlock tickslock; |
yield函数实现就是封装了一下sched函数,在调用sched之前,将进程的状态设置成了RUNNABLE状态:
1 | // Enter scheduler. Must hold only ptable.lock |
sched中保存intena状态到进程的内核栈中的做法,好像把intena变量放到PCB中更合适,但是XV6没有这么做。从shced函数注释中了解到,如果把intena变量放到PCB中的话,有些情况下会有问题。具体呢,就不去细究了(我也每深究),本文内容太长了,还是以调度为主。这里主要是想表达一个点:scheduler函数给ptable.lock加锁时,pushcli保存的intena没有任何意义。因为最终在切换进程时,会被sched中进程的intena给覆盖掉。同样,在进程回到scheduler函数后,scheduler函数给ptable.lock解锁时,popcli还原的intena也没有任何意义,因为无论intena原来是否开中断,外层的for都会开中断!
最终,一个待调度的进程的内核栈帧就形成了:
一张图片概括yield、scheduler的加锁关系。如下图,进程利用yield进入调度器时会获取ptable的自旋锁(自旋锁内部会关中断,并且将关中断之前的中断状态保存到intena中),在切换到scheduler后(可能)会由scheduler解锁。在从scheduler切换到下一个任务前,(可能)scheduler会获取ptable的自旋锁,在却换到下一个任务后,由任务进行解除ptable的自选锁,注意这里是可能,因为还有可能scheduler的内层循环还没有执行完,以至于内层循环还可以找到下一个待执行的日任务,此时ptable的锁,就是:老进程加锁,新进程解锁:
总的来说,XV6进程调度整体流程是:每个cpu上都运行调度线程,调度线程运行sheduler函数,scheduler不断从ptable取进行任务,然后(swapIn)切换去执行进程任务,当进程任务用完时间片(通过定时器中断)就会放弃cpu的执行权限,(swapOut)切换到内核调度线程继续去调度下一个进程任务。
如果类比于用户态的协程的:对称协程和非对称协程之分吗的话,结合非对称协程的特点:协程的切换需要经过调度协程,而由于XV6进程的调度都必须经内核的过调度线程,所以XV6的调度器模型更像一种“非对称进程”。
作为对比,如果你阅读过Onix的代码,你会发现Onix的调度模型更像是一种”对称进程“,因为Onix的进程切换是两个进程之间直接进行,不存在中间的调度线程。
这里我可以用一张类似sylar的协程调度器模型来总结XV6进程调度模型:
其实CPU Pool和线程池非常像,XV6的每个CPU都互斥到ptable中去取进程,然后去消化进程。唯一的区别就是CPU要和很多寄存器、硬件打交道,但是最终整体的框架思想都是一同百通,
如果你看过sylar的源码,你会深有感触! sylar的协程调度器模型和XV6进程的调度模型不能说像,只能是真的一模一样! sylar是一个C++的基于协程的网络框架。我之前也有写过sylar的博客,这里推荐大家去看看:https://blog.csdn.net/m0_52566365/article/details/135991331。
总结
XV6有很多地方写的很暴力,有很大的优化空间,比如:
XV6做法 | Onix做法 | |
---|---|---|
内存管理 | 不管是物理内存还是内核内存一股脑使用kalloc,用户页表所有的内容都靠kalloc | get_page(使用256个页管理4G物理页,专门给页表和页框分配内存/page) + alloc_kpage(专门给内核分配内核页所有的页目录都采用alloc_kpage/page) + kmalloc(使用了内存池专门管理内核中的小快内存/byte) |
内核对系统调用参数的获取 | 直接访问用户栈空间 | 使用ebp、edi、esi、edx、ecx、ebx寄存器获取系统调用参数 |
软件定时器 | 没有实现软件定时器 | 利用链表实现了软件定时器 |
内存探测 | 未实现内存探测 | loader实现了内存探测 |
idle任务 | 没实现idle任务 | 实现了idle任务 |
… | … | … |
从上表可以看到Onix每一项都是存在优势的。但是Onix唯一的缺点,也是我读了Onix源码又来读XV6源码的原因:Onix是一个单核OS。其实读完XV6了解了多核OS的实现后,也没感到很大的震撼,多核CPU无非就是比单核CPU多了几套eip、esp、eflag等cpu相关的寄存器,cpu访问共享资源的的时候注意加锁就好了。
最后谈谈XV6调度模型的优化:从XV6进程调度模型图我们可以看到,XV6的调度模型可以参考Muduo的One loop per thread 思想(可能说Muduo的One loop per thread思想参考了现代Linux对CPU的调度模型更合适?),因为XV6进程调度模型非常暴力,所有cpu共享有一个任务池(ptable),锁的竞争非常激烈。我们可以考虑让每个cpu都拥有一个自己独立的ptable(当然里面还是有自旋锁),由一个cpu负责负载均衡,将任务均匀的分发给各个cpu,需要修改cpux内部数据结构时,其他cpu只需向cpux的回调队列中添加操作函数即可,具体的操作还是由cpux自己完成。也即One loop per CPU。如下图:
终于4干完了这篇文章,字数预计上万了,第一次写这么长的文章,也是真的用心了。创作不易,赏个赞把!
参考资料
多处理器规范:https://pdos.csail.mit.edu/6.828/2014/readings/ia32/MPspec.pdf
XV6的官方中文文档:https://pdos.csail.mit.edu/6.828/2018/xv6/book-rev10.pdf
Onix单核操作系统:https://github.com/StevenBaby/onix/
APIC中断讲解比较好的范文:https://blog.csdn.net/weixin_46645613/article/details/119207945
多核处理器启动博客1:https://zhuanlan.zhihu.com/p/394247844
本章完结