操作系統

操作系統

author
李治军
Created
Dec 4, 2023 04:58 AM
Last edited time
Jun 28, 2024 09:04 PM
Tags

1.操作系统基础

1.2开始揭开钢琴的盖子

notion image
开机时,CS=0xFFFF IP=0x0000然后CPU尝试从0xFFFF0开始寻址这里也是主板中BIOS保存的地址,在寻址成功以后就会接着往下运行,检查基本的硬件设备。
然后从磁盘0磁道0扇区读取512字节到0x7c00处,这里的0磁道0扇区即操作系统的引导扇区。然后设置cs=0x07c0 ip = 0x0000也就是说把当前的ip指向内存中的引导代码块。
notion image
引导扇区的代码是一块汇编代码,而引导扇区将0x7c00处的256个字节读取到0x9000处,腾出空间。
接下来再跳转到复制后下一行的地址处继续执行,因为当前执行地址还在0x7c00附近,所以这里直接跳转但是还是顺序执行的。
跳转到go处以后就到了加载setup模块了,这里主要是为了将磁盘中的OS代码读入内存中,执行。这里的读取从磁盘的第二个扇区开始读,首先读取setup四个扇区,然后在读取后面的system模块(OS代码。
notion image
读取了4个setup扇区以后就开始加载系统第一个界面代码,并且展示到屏幕上,接下来跳转到read_it处加载后面的os代码,最后跳转到刚刚加载的setup代码处,结束bootsect部分。

后记

所以整个操作系统加载部分主要依赖于三个文件bootsect.s\setup.s\head.s。首先操作系统会从磁盘的第一个扇区去查找bootsect文件,bootsect文件会将setup以及后面system内核代码加载进内存,并且按照下图的格式存放。(BIOS中断是在读取0扇区之前就处理好的)
notion image
如图所示,bootsect首先会把自己从0x7c00(在第一次读入时会将它存放在这个位置)移动到0x90000处,然后再将setup读到他的后面,将system读到0x10000处。然后从bootsect中跳出,进入setup程序,该程序的主要作用是首先把从磁盘第二个扇区开始的4个扇区中的setup模块加载到内存紧接着bootsect后面的位置处,然后利用BIOS中断0x13取磁盘参数表中当前启动引导盘的参数,接着在屏幕上显示Loading System字符串,再把磁盘上setup后面的system加载至0x10000处。随后确定根文件系统的设备号,如果没有指定,则根据所保存的引导盘的每磁道扇区数判别出盘的类型和种类并保存其设备号于root_dev,最后长跳到setup程序的开始处。
notion image
notion image
notion image
notion image
最后setup会通过jumi 0,8打开保护模式修改寻址模式,从16位寻址转移到
notion image
notion image
变成32位寻址以后,cs会从gdp中查找对应的表项和ip产生基址在和ip加在一起,这个表的基址是32位的。而这个setup在这之前就将这三个表初始化好了。
notion image
这里有一点换算关系,一个十六进制数需要4位来表示,一个表项需要一个word来表示,也就是说需要64位,如图所示,而刚好这里gdt的第一项中表示段基址的几个部分组合起来是全0。又因为刚刚的setup已经把system转移到了内存0处,所以这一条指令也就是跳转到system起始的地方开始跑。

实验一

notion image
 
$ tar -zxvf hit-oslab-linux-20110823.tar.gz -C /home/shiyanlou/
$ cd ./linux-0.11/
$ make all
SETUPLEN=2
SETUPSEG=0x07e0
entry _start
_start:
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#36
    mov bx,#0x0007
    mov bp,#msg1
    mov ax,#0x07c0
    mov es,ax
    mov ax,#0x1301
    int 0x10
load_setup:
    mov dx,#0x0000
    mov cx,#0x0002
    mov bx,#0x0200
    mov ax,#0x0200+SETUPLEN
    int 0x13
    jnc ok_load_setup
    mov dx,#0x0000
    mov ax,#0x0000
    int 0x13
    jmp load_setup
ok_load_setup:
    jmpi    0,SETUPSEG
msg1:
    .byte   13,10
    .ascii  "Hello OS world, my name is LZJ"
    .byte   13,10,13,10
.org 510
boot_flag:
    .word   0xAA55
notion image
这一段实验前面的内容都很好写,就最后面这一段,有点难理解,因为对汇编没那么熟,所以里面的一些代码看起来多少有点抓瞎。而且这个debug也很难用,打断点我都费点劲,最后发现跟设定不一样的部分是哪个sectors也就是磁盘参数表里的倒数第二项。我一直没想明白[12]这里面的数字是什么,后面才知道就是地址的立即数偏移。由于这个磁盘相关的信息是从4这个偏移开始存放的16个字节数据,所以最后一位的下标应该是0x13,也就是说倒数第二项的下标应该是0x12如果要用10进制表示应该是18而不是12,或者直接加上一个0x前缀也是一样的结果。
notion image

1.3操作系统启动


内核态,和内核段
DPL用来表示目标内存段的特权级
CPL用来表示用户内存段的特权级
DPL保存在GDT表中
只有当满足DPL≥CPL时,才允许调用。

硬件提供了主动进入内核的方法

对于intel X86,那就是中断指令int,int指令使CS中的CPL改成0,进入“内核”,这是用户程序发起的调用内核代码的唯一方法。
系统调用的核心流程:
1.用户程序中包含一段int指令的代码(通过库函数
2.操作系统写中断处理,获取想调用程序的序号
3.操作系统根据编号执行相应代码
notion image
IDT表里的部分系统调用通过init中的部分函数初始化,初始化完成以后int 0x80在idt表中的DPL被设置成3,所以当我们使用int 0x80用户态进入内核态时才可以完成调用。进入了这个中断以后就可以通过idt表中设置好的偏移给cs和ip重新设置跳转地点了。这里CS设置成8ip设置成system_call。等于是进入内核的代码段中找system_call去处理。此时cs段对应的最后两位cpl就已经被设置成0了,也就是说进入内核段以后可以随意访问内核段的代码了。
notion image

实验二

notion image
notion image

多进程图像(流水线模型)
读写PCB,OS中最重要的结构,贯穿始终
要操作寄存器完成切换L10-L12
要写调度程序L13-L14
要有进程同步与合作L16-L17
要有地址映射L20
进程调度之间为了保证不因为访问其他进程内存地址,使用了映射表来隔离多个进程之间的内存区域,而相对的线程,则是在同一个映射表内做进程调度。而线程切换又涉及到指令的切换,以及同一个映射表里的内存地址的切换。
关于用户级线程切换主要包含两个主要的部分,一个是create函数,里面主要是生成一个包含当前线程状态信息的TCB数据结构,以及线程栈,并且把栈和TCB关联。另一个是yield函数,这个函数主要是操作线程切换,管理当前栈的指针esp,保存切换栈的指针。
核心级线程主要是为了解决调用内核级别资源时被内核操作阻塞造成整个进程被阻塞的情况,这种情况下系统无法看到用户级线程中的其他线程,使用核心级线程时很必要的。
notion image
因为多核使用的同一套MMU映射,所以可以直接同时支持多核并行起来。而多进程其实也没办法发挥多核的优势,因为还是要切换映射之类的上下文数据。
notion image
使用核心线程时需要创造两套栈,在用户态跑用用户栈,在核心态跑用核心栈。需要使用到内核栈的时候就通过int中断来从用户态进入内核态,此时还要把用户态中使用的栈地址以及PC、CS压入内核栈。当返回用户栈的时候把刚刚压入的几个栈全弹出来,就可以通过这几个东西返回刚刚的用户态。
notion image
核心线程切换和用户线程切换几乎是一样的,同样也是在两个TCB之间进行交换esp。而创建核心栈的过程略有不同,首先需要创建一个TCB,创建一个内核栈,然后对内核栈的内容进行初始化。将用户栈的地址以及相关信息压入内核栈,再将内核栈与TCB关联,设置iret等中断出口,最后设置状态并且入队。
notion image
notion image
notion image
创造TSS的过程实际上就是拷贝当前的内核栈的快照,包括里面包含的用户线程中的栈等相关信息。通过内核栈中的这些参数就可以创建一个子线程了。根据途中可以看到process的参数也是从栈顶开始取的。
notion image
创建一个线程在linux这个初始版本里其实就是创建一个tss快照的过程,我们只需要将当前进程的栈相关参数全部传入,然后开辟一块内存空间,将TSS相关指针设置成内核数据段的申请的位置,然后将原(父进程里的用户栈相关数据拷贝过来,再填充好TSS里相关的信息(从copy_process中传来的。就可以创建一个和原进程几乎一摸一样的进程了,只是这时候的子进程还没有运行,我们通过eax来判断fork返回的是父进程还是子进程,因为在填充TSS也就是初始化TSS的时候我们就把eax设置为0了,这里的fork就会判断成生成好了子进程并且执行后面的代码。
这里的TSS还有些值得说的细节,TSS是储存在GDT表中的,紧接着在内核数据段的后面,我们使用switch_to的时候会传递一个next参数过去,这个参数代表了TSS在GDT表中的选择子,通过内存数据段以及next的还原可以得到准确的GDT表偏移。而通过这个偏移我们就可以找到对应的TSS数据,此时CPU会根据找到的GDT表项中的某些位判断是TSS段,并且将当前的寄存器保存到当前TR指向的TSS中,并且将NEXT对应的TSS取出到寄存器中,将TR指向next表示的TSS段。
notion image
而调用创建线程的代码在系统调用call sys_fork的部分,sysfork会创建一个TTS并且初始化好,而等会通过TTS里eax的值我们就可以在用户线程里判断是父还是子来决定是否要执行新的语句。
notion image
当父进程被阻塞,此时就会调用reschedule,切换到子进程去执行任务。
notion image
这张图片里的代码其实就是对应的创建内核栈以及PCB的过程,申请内存空间(从数据段中请求一页4k),创建内核栈。esp0是指向这页内存的顶端。这里的10是内核数据段。于是使用ss0和esp0我们就可以找到内核数据段中申请到的空间栈顶了。而这里的ss和esp则是原内核栈中保存的用户栈地址,用于返回用户线程时恢复用。
 
notion image

实验五


CPU任务调度算法
SJF:短作业优先
将短作业提前,提高周转时间,周转时间等于:任务开始时间点/任务数
RR:轮转调度,时间片调度
notion image
/* this is the scheduler proper: */

	while (1) {
		c = -1;
		next = 0;
		i = NR_TASKS;
		p = &task[NR_TASKS];
        // 这段代码也是从任务数组的最后一个任务开始循环处理,并跳过不含任务的数组槽。比较
        // 每个就绪状态任务的counter(任务运行时间的递减滴答计数)值,哪一个值大,运行时间还
        // 不长,next就值向哪个的任务号。
		while (--i) {
			if (!*--p)
				continue;
			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
				c = (*p)->counter, next = i;
		}
        // 如果比较得出有counter值不等于0的结果,或者系统中没有一个可运行的任务存在(此时c
        // 仍然为-1,next=0),则退出while(1)_的循环,执行switch任务切换操作。否则就根据每个
        // 任务的优先权值,更新每一个任务的counter值,然后回到while(1)循环。counter值的计算
        // 方式counter=counter/2 + priority.注意:这里计算过程不考虑进程的状态。
		if (c) break;
		for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
			if (*p)
				(*p)->counter = ((*p)->counter >> 1) +
						(*p)->priority;
	}
    // 用下面的宏把当前任务指针current指向任务号Next的任务,并切换到该任务中运行。上面Next
    // 被初始化为0。此时任务0仅执行pause()系统调用,并又会调用本函数。
	switch_to(next);     // 切换到Next任务并运行。
}
从这一段代码可以看出来,0.11调度函数里每次调度都去找counter最大的值,而当所有的值都为0(时间片全部用完的情况下,运行下面的代码,将counter重置,但是要注意的是这个循环里不仅仅重置了所有状态为RUNNABLE以及时间片为0的任务,也同时将还在阻塞中的任务counter增加了,而且这些阻塞中的任务counter一定比较大,这也就变相的提高了io等进程优先级提高了。这也就是提高了前台任务的优先级,让响应时间变短了。
notion image
同时这个counter由于同时按照counter进行时间片轮转,所以也近似SJF调度,因为执行时间短的任务还是会较快的完成。
notion image
notion image
notion image
notion image

信号量
notion image
notion image
这里是用了两个信号量empty表示缓冲区数量,full表示可用资源数量,metex用来当作锁。
cli和sti使用cpu的中断标志来实现对进程的中断阻塞,这样也可以避免多个进程操作同一个资源。
notion image

死锁预防的方法
死锁预防
在进程执行前,一次性申请所有需要的资源,不会占有资源再去申请其他资源
需要预知未来,编程困难
许多资源分配后很长时间后才能使用,资源利用率低
对资源类型进行排序申请,资源申请必须按序进行,不会出现环路等待
仍然造成浪费
死锁避免
如果系统中所有进程存在一个可完成的执行序列p1-pn,则称系统处于安全状态
notion image
死锁回滚+恢复
使用银行家算法检测到死锁了以后,再对死锁进行回滚,但是选择进程的优先级,和如何实现都很复杂。但是这里的银行家算法不是每次都请求执行,而是出现死锁再使用。
死锁忽略
短运行时间的机器上通常都直接忽略

内存使用与分段
重定位:编译时重定位、载入时重定位
编译时重定位要求使用时内存空间空闲,载入时则需要重复的定位。
运行时重定位是最合适的,因为程序还需要在休眠时和磁盘里的程序交换,所以其实最好的重定位时机还是程序运行时。
基地址base放在PCB中,然后将程序放到基址内存中,每执行一条指令的时候都要进行地址翻译,进程切换的时候也根据PCB中保存的基地址一起切换。
而实际上的操作系统将程序分为多个段(程序段、数据段、库函数段等)而这些段的起址信息都保存在一个LDT表中,程序运行时指令通过查询LDT表确定要翻译的地址,这个LDT表就保存在每一个进程的PCB中。
notion image
引入分页
解决内存分区导致的内存效率问题
内存分区一定会造成很多小的内存碎片.
所以直接对内存分成页,将内存分成小单位的分页,这样就可以直接通过对每个程序分配页的空间来减少空出来大块的内存。
notion image
简单的使用分页会导致每个程序都需要包含一个存储所有页项的表,会带来很大的存储负担,所以采用了分级页表,新添加一个页目录来表示1000页,然后再通过页目录来查找页。
notion image
实际使用时使用的策略是虚拟内存分段+物理内存分页。程序需要储存时,首先通过MMU查找到对应的虚拟内存,然后通过虚拟内存中目录分页查找到对应的物理分页地址,然后再储存到物理分页地址中。
notion image
实际上就如上图所示,首先存储的时候将程序分为多个段,然后把这些段的概念放入逻辑分区里,每个逻辑分区又对应了一个页表,页表中储存的是每个页号对应的实例物理地址的块。查找时就通过上图中的逻辑地址A,段号→页号→页内偏移来找到实际的数据地址。
notion image
创建一个环形数据结构,再指针转一圈的时间里如果访问过就留下,如果没访问过就删除。
这个朴素的算法的问题是,当这个圈时间过长,·页面中的1过多,就可能导致每次被替换掉的只有当前被置为0的位(因为这个算法的基础就是将当前开始的每一个1都置换成0,以供下一次循环判断是否使用过),这个算法就退化成了FIFO。
notion image
notion image
notion image

IO
notion image
notion image
call_addr是对应dev的处理函数。
notion image
notion image
notion image
实际上编写驱动,就是编写一个抽象的dev文件,供系统调用,把你写的函数注册到各种调用表里,然后在系统打开dev文件的时候就会通过文件里的信息调用这些你注册的函数。
notion image
notion image
从一个键盘产生的端口里读出扫描码,然后将扫描码作为参数传入key_table找到这个扫描码对应的处理函数。这个处理函数又会根据扫描码找到对应类型的表,然后根据这个找到的表以及扫描码(偏移量)就可以找到对应键的ascii值了。
notion image
将字符通过read_q传入等待队列的头部,然后在回显的过程中,还会对字符进行处理再返回给scanf以及屏幕显示。
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image