二、linux操作系统
2.1、操作系统的启动
-
(1) 开机时,系统复位,CS:IP被复位为0xFFFF0,于是跳转到0xFFFF0处执行命令。此处为jmp指令。跳转到段地址0xF000:0xE05B处
(0xF000段地址均在ROM中,也即bios存储区域)。执行开机自检等工作后,加载主引导扇区到0x7C00处。 -
(2)主引导扇区再将自己复制到0x90000处,并跳转到此处执行代码。(为什么不直接加载到此处,还需要多一步复制的过程?为了兼容之前的机器)
-
(3)利用 BIOS中断(0x13) 将setup(四个扇区)加载到0x90200处,利用BIOS中断(0x10)显示开机界面,将操作系统system加载到0x10000处。
(后续还要移动,之所以现在不移动是为了不覆盖BIOS中断表,setup还需要用到BIOS中断)- 读操作系统时尽量一次将整个磁道的所有扇区读入,保证每次不超过64kb(不跨段)。
- 检查使用哪个根设备,跳转到setup中。
-
(4)setup利用BIOS中断读取系统数据到0x90000处,覆盖掉Bootsect。(光标位置、扩展内存大小等)
-
将system向下移动到0x00000处。
-
加载中断描述符表寄存器(IDTR)和全部描述符表寄存器(GDTR)、开启A20地址线,设置中断控制芯片等。全局描述符表暂放在0x9xxxx处
说明:IDTR中存放的是IDT在内存中的基地址和其限长。
-
修改控制寄存器CR0,进入32位保护模式
进入保护模式后,段寄存器保存的为描述符在描述符表中的偏移位置,也即jmp的段值是指段选择符
低2位标识特权级,第3位0/1为全局/局部描述符,其余为索引。 -
转到system中的head
-
-
(5)system中的head
- 重新设置中断描述符表(IDT)、全局段描述符表(GDT)以及描述符表寄存器,放在内核数据段,位置在head最后处。
中断描述符表中的项被称为中断门描述符,初始都设置为哑中断函数 - 判断A20地址线是否开启
------------------------------------------------上面为分段机制,下面为分页机制------------------------- - 页目录表放在绝对物理地址0处,CR3保存其地址
保护模式下使用GNU汇编,从左向右赋值 - 设置页表项
每个页表包含1024个表项,每个表项需要4个字节,每个页表本身需要4KB空间,第一个页表放在0x1000处。
每个表项代表内存4KB,若想寻址16MB,则需要4个页表。(根据CSAPP,现代页表是分级机制,是不断往下细分的方式) - 转到main函数执行
- 重新设置中断描述符表(IDT)、全局段描述符表(GDT)以及描述符表寄存器,放在内核数据段,位置在head最后处。
BIOS中断表也没必要放在0x00000处,这样可以避免操作系统的移动(从0x10000移动到0x00000)。
2.2、c语言内嵌汇编
输入输出共用同一个寄存器时,使用“0”约束修饰输入变量
此部分参考:link
在此需要强调的是,在指明input operands的情况下,即使指令不会产生output operands,其:也需要给出。例如asm (“sidt %0\n” : :“m”(loc));
2.3、系统调用的实现
为了防止应用程序改变操作系统,硬件提供特权级保护。通过比较当前特权级(CPL)和目标特权级(DPL),只有满足
C
P
L
≤
D
P
L
CPL\le DPL
CPL≤DPL (内核为0,用户为3)的代码才能执行,而操作系统为了给用户程序提供服务就需要提供一些接口:系统调用。
系统调用是通过中断实现的,具体而言是:
(1) 通过库函数调用int 0x80(通过宏来展开系统调用函数write(),宏中内嵌汇编实现此功能),需要传递的参数为系统调用功能号。
(2) 在中断描述符表中找到对应中断处理函数system_call (此部分为特意设置该表项的DPL=3,使得用户程序可以调用)
(3) 在system_call中根据系统调用号在sys_call_table[]中找到并执行相应的处理函数 (通过函数指针数组实现)
在系统调用期间,ds、es指向内核数据空间,fs指向用户数据空间,因此可以完成内核和用户之间的数据交换。
2.3.1、实验部分
添加一个新系统调用的流程就为:
(1) 为新系统调用设置新的系统调用号,并注意用相应的宏展开该系统调用
(2) 在系统调用函数指针数组中,添加新的处理函数
(3) 在内核中实现新的处理函数
在linux0.11实验三系统调用中,在内核下使用printk是个大坑,并不能使用printf的%s等,否则将提示段错误。最好仅仅用来输出一部分写好的字符串。
2.3.2、fork
linux 0.11中定义每个进程最大可用虚拟内存空间为64MB,最大任务数为64,共计4GB。
(1) 在主内存区中申请一页内存p,复制任务数据结构(PCB,task_struct)到p开始处
(2) 设置任务状态段(TSS)各寄存器的值,设置内核堆栈esp0开始位置位于p末尾处,ss0被设置为内核数据段选择符(0x10)。TSS也在PCB中,其中保存的为进程运行的所有信息(CPU各寄存器的值,IP的值等),当前正在执行进程的TSS由任务寄存器(TR)指示。
为什么tss.ss0被设置为0x10?
linux0.11最多能管理16MB内存,而内核数据段长度为16MB,因此内核代码可以寻址整个物理内存的任何位置。
2.3.3、进程切换
linux0.11使用TSS和ljmp完成进程的切换,但这个指令耗时较长。
利用内核堆栈切换的方式更高效。
1、子进程堆栈的构造;
在fork过程中,主要的函数是copy_process,其利用父进程信息创建子进程。堆栈中的内容需要参考其执行过程。
- (1)用户执行中断函数过程(进入内核):
- 利用内核栈切换其实是借用了iret指令将内核栈与用户栈联系起来。因此在构造新进程内核栈时需要效仿,在内核栈中压入相应内容ss、sp、eflags,cs,ip,有了这些我们便可以回到用户代码处以及找到用户堆栈。
- (2) 执行系统调用过程(在内核中执行系统调用):
- a.肯定是系统调用sys_call引起的进程的切换,因此后续要考虑ret_from_sys_call中弹出的寄存器,以便恢复到用户调用系统调用前的状态接着执行。
- b.调用sys_fork创建新进程,还需考虑 sys_fork函数中压入的堆栈。(sys_fork会首先调用find_empty_process获得子进程号,保存在eax中,因此父进程返回子进程号);
- c. 最后执行copy_process,其中a.b.可以合为一步,因为本质上最终压栈的参数全部传给了copy_process(子进程的eax设置为0,其代表最终的返回值,因此子进程返回0),只需利用这些信息构造即可。
- (3)执行schedule调度过程(执行完sys_fork后,会判断任务状态、时间片等信息,可能会执行调度):
- 主要涉及函数为switch_to。因此最后要考虑的是,切换是在switch_to函数中进行的,内核切换后仍返回该函数,需考虑switch_to函数中压栈的情况。执行switch_to时
- 若调度到父进程,判断条件满足直接返回结束,父进程会一级级的返回;
- 若调度到子进程,执行下述2过程,子进程执行完switch_to后,返回一个特意构造的函数,在其中弹栈,执行iret直接返回到用户程序
- 主要涉及函数为switch_to。因此最后要考虑的是,切换是在switch_to函数中进行的,内核切换后仍返回该函数,需考虑switch_to函数中压栈的情况。执行switch_to时
2、switch_to具体切换过程;
在C语言中调用switch_to时,已经把其参数压入栈中,压入顺序为从右向左,最后压入返回地址。
在switch_to函数中,由于是C语言调用该函数,因此首先保存栈帧,之后需要完成的内容是:切换进程PCB、保存新进程的内核栈指针到TSS中(此处的tss为全局共享变量,改变此变量后仍然和Intel中断处理兼容,current和tss的初值在sched.c),切换内核栈,切换LDT。
3.需要注意的问题:
- (1).在sched.c中schedule函数调用switch_to时,需要参数pnext,我们想让pnext表示下一个进程的PCB结构,需要注意的是,此变量一定要设置初值(进程0的PCB结构),否则在系统刚开始运行时会出现问题,next默认为0,即没有进程需要调度时,运行进程0,如果不设置初值,将会调度一个随机值,就会出现问题。
- (2). 在switch_to函数中,切换内核栈后,仍有一步操作为movl 12(%ebp),%ecx,此处的问题是既然内核栈已经切换,而12(%ebp)对应内容位于原进程堆栈上,在新进程内核栈中并没有此参数。
解答:此步之所以能够执行成功,是因为内核栈切换只改变%esp,此时%ebp仍是指向原进程堆栈,因此就可以用原先参数。
2.3.4、execve
基于内核栈的切换的原理我们已经获知,此函数只不过是改变代码及用户栈。因此只需改变ss、sp、eflags,cs,ip中的ip和sp即可。
2.4、内存管理
物理内存的使用共计分为内核模块区,高速缓冲区,虚拟盘(如果有的话),主内存区;
高速缓冲区是为了应对块设备,对相应设备的读写均需要在此中转一次;
内核可以直接访问高速缓冲区,但需要通过mm访问主内存区;
三、minix中驱动程序实现方式
minix为微内核设计方式,共分为四层。从上到下依次为用户进程、服务器进程、驱动进程、内核进程(包含时钟和系统任务)
只有系统任务(运行在内核模式下)有权限访问各内存位置,有能力完成拷贝操作。
典型调用过程为:用户进程读文件,转换为信号方式,向文件系统(服务器进程)发送信号,文件系统再调用系统任务访问IO接口,当IO过程完成后,其向驱动程序发送中断信号,驱动完成拷贝后再向文件系统发送信号,文件系统再向用户进程发送信号。
(0)主程序
驱动程序的执行方式为:进行硬件相关的初始化,进入循环等待接收中断信号,接收到中断信号后执行后续操作(读/写/更改设备参数等操作)。这个流程对很多驱动都适用,是一个通用的程序。具体是通过一个结构体实现,结构体中的元素全为函数指针,这样具体的程序实现函数后便可通过这种方式创建结构体,再传给此主程序即可。
(1)RAM盘驱动程序
实际上为一段内存区域。分为若干设备。
init:初始化各设备。
prepare:判断索引是否在此区域,不在则返回空指针(设备结构体类型)。
transfer:根据次设备号,调用系统任务sys_vircopy/sys_phycopy进行拷贝。
参考
《操作系统设计与实现》
《Linux内核完全注释》