操作系统实现学习笔记(上)
1 | #!Makefile |
- kernel.ld
1 | /* |
一个操作系统的实现阅读笔记
实模式下,段值还是可以看作地址的一部分, 段值为xxxxh表示以xxxx0h开始的一段内存, 而保护模式下, 段值表示为一个索引, 指向一个数据结构的表项, 即GDT/LDT, 它的表项也称描述符(Descriptor). 所以GDT的作用是提供段式存储机制.由段寄存器和GDT中的描述符共同提供的.
物理地址 = 段值 * 16 + 偏移
数据段和代码段描述符
这里写一下描述符的各属性,仅做查询方便之用.
P位 存在位. P=1表示段在内存中存在.否则为0
DPL描述符特权级,分0 - 3,数字越小特权级越大.
S位指明描述符是数据段/代码段描述符(S=1), 还是系统段/门描述符(S=0)
TYPE描述符见下
G位 段界限粒度位, G=0 时段界限粒度为字节,G=1界限粒度为4kb
D/B位, 可执行代码段描述符中,是D位, D=1默认情况下用32位地址及32位或8位操作数,D=0时则是16和8. 向下扩展数据段描述符中,为B位,B=1时,段的上部界限为4GB,0时为64kb
描述符堆栈段描述符, 为B位, B=1时, 隐式的堆栈访问指令使用32位堆栈指针寄存器esp,D=0时,用sp.
AVL位保留位被系统软件使用.
选择子
TI位是区别GDT选择子和LDT选择子的关键.如果TI被置位,那么系统就从当前LDT中寻找相应描述符.
进入保护模式的步骤:
- 准备GDT
- 用lgdt加载gdtr
- 打开A20
- 置cr0的PE位
- 跳转,进入保护模式
$ 表示当前行被汇编后的地址, $$表示程序被汇编后的开始地址.
“一致”: 当转移的目标是一个特权级更高的一致代码段,当前的特权级会被延续下去,而向特权级更高的非一致代码段的转移会引起常规保护错误,除非使用调用门或者任务门. 当目标代码的特权级低的话,无论它是不是一致代码段,都不能通过call或者jmp转移进去. 所有的数据段都是非一致的所以不可能被低特权级的代码访问到.但与代码段不同的是, 数据段可以被更高特权级的代码访问到,而不需要特定的门.
补一个汇编的tips:
- COUNT EQU 100;令COUNT的值为100,存储器中为变量分配 0个字节
- COUNT DB 100 ;令COUNT的值为100,存储器中为变量分配 1个字节
- COUNT DW 100 ;令COUNT的值为100,存储器中为变量分配 2个字节
- count EQU $-ARRA 定义了一个常量,不占用内存单元,代码段中使用它,等价于使用一个立即数。
- count DW $-ARRA 定义了一个变量,占用2个字节的内存单元,代码段中使用它,就变成一个[偏移地址]。
字符串处理指令stosb, lodsb, movsw,scasb,rep
(1) lodsb、lodsw:把DS:SI指向的存储单元中的数据装入AL或AX,然后根据DF标志增减SI
(2) stosb、stosw:把AL或AX中的数据装入ES:DI指向的存储单元,然后根据DF标志增减DI
(3) movsb、movsw:把DS:SI指向的存储单元中的数据装入ES:DI指向的存储单元中,然后根据DF标志分别增减SI和DI
(4) scasb、scasw:把AL或AX中的数据与ES:DI指向的存储单元中的数据相减,影响标志位,然后根据DF标志分别增减SI和DI
(5) cmpsb、cmpsw:把DS:SI指向的存储单元中的数据与ES:DI指向的存储单元中的数据相减,影响标志位,然后根据DF标志分别增减SI和DI
(6) rep:重复其后的串操作指令。重复前先判断CX是否为0,为0就结束重复,否则CX减1,重复其后的串操作指令。主要用在MOVS和STOS前。一般不用在LODS前。
上述指令涉及的寄存器:段寄存器DS和ES、变址寄存器SI和DI、累加器AX、计数器CX涉及的标志位:DF、AF、CF、OF、PF、SF、ZF
CLD与STD是用来操作方向标志位DF(Direction Flag)。CLD使DF复位,即DF=0,STD使DF置位,即DF=1.用于串操作指令中。
jc = Jump if Carry 当运算产生进位标志时,即CF=1时,跳转到目标程序处。
处理器通过CPL,DPL,RPL三种特权级进行特权级检验.
CPL是当前执行的程序或任务的特权级,被存储在cs和ss的第0位和第1位上. 通常CPL随着代码所在段的特权级进行改变,但在当处理器访问一个与CPL特权级不同的一致代码段时,CPL不会被改变.
DPL表示段或者门的特权级, 被存储在段描述符或者门描述符的DPL字段中.当当前代码段试图访问一个段或者门时,DPL会和CPL以及段或门选择子的RPL相比较,根据段或者门类型的不同,DPL会被区别对待:
- 数据段,调用门,TSS规定的是最低特权级, 即 CPL <= DPL
- 非一致代码段规定的是特权级, 即 CPL = DPL (and RPL小于DPL)
- 一致代码段和通过调用门访问的非一致代码段规定的是最高特权级, 即CPL >= DPL (不检查RPL)
and 特权级值越小说明特权级越高….
RPL通过段选择子的第0位和第1位表现出来的. 处理器通过检查RPL和CPL来确认一个访问请求是否合法. 即两者特权级低的决定了访问的下限.当被调用过程才从调用过程接收到一个选择子时,会把选择子的RPL设成调用者的特权级. 于是当操作系统用这个选择子去访问相应的段时,处理器会用调用过程的特权级RPL,而不是更高的操作系统过程的特权级CPL进行特权检验.
程序控制转移的发生可以由指令jmp,call,ret,sysenter,sysexit,int n,iret或者中断和异常机制引起.转移方式分为两大类,一是通过jmp和call的直接转移,二是通过某个描述符的间接转移. 细分为
- 目标操作数包含目标代码段的段选择子
- 目标操作数指向一个包含目标代码段选择子的调用门描述符
- 目标操作数指向一个包含目标代码段选择子的TSS
- 目标操作数指向一个任务门,这个任务门指向一个包含目标代码段选择子的TSS
门描述符, 主要是定义目标代码对应段的选择子,入口地址的偏移和一些属性:
门分为调用门,中断门,陷阱门,任务门. 调用门特权级规则如下, 对照着前面的特权级访问规则就很容易明白.这里我们举个例子想由代码A转移到代码B,运用于个调用门G,即调用门G中的目标选择子指向代码B的段.
如果一个调用或跳转指令在段间而不是段内进行的, 那么我们称之为长跳转.长调用如call执行指令时被压栈的不仅有eip,还应该有cs.
每一个任务最多可能在4个特权级转移.所每个任务实际需要4个堆栈,从TSS数据结构获得ss和esp. 下图是32位TSS.
当任务在不同特权级间进行转移的时候, CPU在整个过程中做的工作
- 根据目标代码段的DPL(新CPL)从TSS中选择应该切换至那个ss和esp
- 从TSS中读取新的ss和esp,在整个过程中如果发现ss,esp或者TSS界限错误都会导致无效TSS异常.
- 对ss描述符进行检验,如果发生错误,同样产生#TS异常.
- 暂时性地保存当前ss和esp的值.
- 加载新的ss和esp.
- 将刚保存的ss和esp值压入新栈.
- 从调用者堆栈中将参数复制到被调用者堆栈中,复制参数的数目由调用门中Param Count一项决定.如果为0则不会复制
- 将当前的cs和eip压栈.
- 加载调用门中制定的新的cs和eip,开始执行被调用者过程.
逻辑地址 —(分段机制)—> 线性地址 —(分页机制)—> 物理地址
分页机制转换时,先是由寄存器cr3制定的页目录中根据线性地址的高10位得到页表地址,然后在页表地址中根据线性地址的第12到21位得到物理页首地址,将这个首地址加上线性地址第12位就是物理地址.
cr0的最高位PG位为1则开启分页机制.
PDE结构
1 | | P | R/W | U/S | PWT | PCD | A | 0 | PS | G | Avail | Page Base-Table Address| |
分别对应Present, Read/write. User/Supervisor. Write-through, Cache disabled, Accessd, Reserved, Page size, Global page, Available for system programmer’s use. 页表基址.
PTE结构
1 | | P | R/W | U/S | PWT | PCD | A | D | PAT | G | Avail | Page Base Address| |
分别对应Present, Read/write. User/Supervisor. Write-through, Cache disabled, Accessd, Dirty, Page Table Attribute Index , Global page, Available for system programmer’s use. 页基址.
cr0 的wp位是supervisor的保护位(CPL < 3 是Supervisor), 当cr0的wp位为0,即使用户页面的R/W=0, 系统级程序耶具备写权限, 如果为1,则系统级程序也不能写入用户级只读页.
PWT用于控制单个页或页表的缓冲策略,为0是使用write-back,为1是使用write-through. 当cr0的CD(Cache-Disable)为被设置时会被忽略.
PCD用于控制单个页或页表的缓冲,为0时可以被缓冲,为1不可以.同样手cr0的CD位影响.
D表示页或页表是否被写入.
cr3 指向页目录表, 又叫作PDBR(Page-Directory Base Register), 高20位页目录表首地址的高20位,页目录表首地址的低12位会是0(4kb 对齐).
tips: es:edi指向页目录表的开始, 具体见下, 当第一个PDE赋值时,循环已经开始,es:edi每次循环会自动指向下一个PDE 或者PTE的首地址.
1 | ; 启动分页机制 -------------------------------------------------------------- |
分页机制:
利用中断15h可以检测机器的内存.
- eax设为0E820h, 调用后为’SMAP’
- ebx放置后续值(为等到下一个地址描述符所需要的后续值)
- es:di指向一个地址范围描述符结构ARDS
- ecx位BIOS填充在地址范围描述符的字节数量20字节.
- ead 0534D4150h(‘SMAP’),BIOS使用此标志对调用者请求的系统映像信息进行校验, 这些信息被BIOS放置到es:di所指向的结构中
ARDS(Address Range Descriptor Structure)的结构
1 | 偏移 名称 意义 |
分页机制可以达到的效果: 先执行某线性地址处的模块, 然后通过改变cr3来转换地址映射关系,在执行同一个线性地址处的模块. 因地址映射已经改变, 所以两次输出不同.
中断
保护模式下中断机制变化, 中断单向量表被IDT代替, IDT中断描述符, 分为中断门,陷阱门,任务门, 作用是将每一个中断向量和一个描述符对应起来.
中断分为不可屏蔽中孤单(NMI)和可屏蔽中断, 分别由CPU的两根引脚NMI和INTR来接收, NMI中断对应的中断向量号通常为2, 其中可屏蔽中断与CPU的关系是通过可编程中断控制器8259A建立起来的. 对他的设置通过相应端口写入特定ICW实现.
OCW有三个,通常用于以下情况:
- 屏蔽或打开外部中断; 向8259A写入OCW1. 其实是写入了IMR(主动屏蔽器)
- 发送EOI给8259A已通知它中断处理结束. 通过向端口20h或者A0h写OCW2实现.
中断或异常发生时的堆栈变化, 没有特权级变换时eflags, cs, eip, (错误码)将一次被压入堆栈, 否则先押入ss和esp. 从中断或异常返回时必须使用指令iretd. 它在返回的同时改变eflags的值. 但是只有当CPL为0时, eflags的IOPL域才会改变,当CPL <= IOPL时, IF才会被改变. 当iretd执行时要先将Error Code主动清除.
通过中断门向量引起的中断会复位IF, 因为可以避免其他中断干扰当前中断的处理. 随后的iret指令会从堆栈上恢复IF的原置,而通过陷阱门产生的中断不会改变IF.
IOPL是 I/O 保护机制的关键之一, 位于寄存器eflags的第12,13位. eflags图如下,
I/O敏感指令只有在CPL<<IOPL时才能执行. popf和iretd可以改变IOPL指令, 运行在ring0程序才能将其改变. popf同时可以改变IF.但是要求CPL<=IOPL.
加载一个文件入内存的话,读软盘时要使用到BIOS中断int 13h, 用法如下:
在将控制权由loader交给内核之前的内存使用分布示意图
1 | ;*************************************************************** |
如图, 0x90000开始的63kb留给了Loader.bin, 0x80000开始的64kb留给了Kernel.bin, 0x30000开始的32kb留给整理后的内核.而页目录和页表被放置在了1mb以上的内存空间.
在进程切换过程中,esp的位置出现在3个不同的区域. 进程栈,进程表(也可称进程控制块PCB),内核栈.
进程表通常由一个结构体(这里是一个s_proc)定义较为方便管理. 保存进程的状态.
一个进程的启动过程:
经历的过程: 进程体 –> 初始化GDT中的TSS和LDT两个进程符 –> 初始化TSS –> 准备进程表 –> 完成跳转,实现ring0->ring1.
一个进程开始之前必须初始化的寄存器: cs, ds, es, fs, gs, ss, esp, eip, eflags.
进程开始之前的核心内容和关系可以分为三个部分:
- 进程表和GDT. 进程表内的LDT selector对应GDT中的一个描述符,而这个描述符所指向的内存空间就在这个进程表内.
- 进程表和进程.
- GDT和TSS. GDT中需要一个描述符对应TSS, 并且初始化它.
进程表需要初始化的主要有3部分: 寄存器, LDT Selector, LDT.
多进程
中断重入 , 因为CPU在相应中断的过程中会自动关闭中断, 需要人为地打开中断, 加入sti指令, 然后, 要保证中断处理过程足够长, 以至于在它完成之前会有下一个中断产生, 在中断处理例程中调用一个延迟函数. 即中断嵌套.
先列一下多进程的步骤:
- 在task_table中增加一项,即多一项任务内容
- 让NR_TASKS加一, 即进程数量加一
- 定义任务堆栈.
- 修改STACK_SIZE_TOTAL
- 添加新任务执行体的函数声明
这里再补充一下进程切换的内容有助于理解:
- clockhander:
一个操作系统的运转过程(例子):
计数器的工作原理: 有一个输入频率如1193180Hz,在每一个时钟周期(CLK cycle),计数器值会减一,当减到0时,就会触发一个输出,如果计数器是16位的,则最大值为65535,默认的时钟中断发生频率就是1193180/65536 约等于 18.2Hz. 我们可以通过改变计数器的计数值来控制频率(时间间隔).
shell的实现(tty)
屏幕显示字符常用的汇编语句是
1 | mov ah, 0Ch ; 0000:黑底 1100:红字 |
其实看张图就明白, 字符对应的字节和位定义如下:
tty的任务框架:
TTY任务开始运行时, TTY都被初始化, nr_current_console会被赋值为0,然后持续循环.而对于每一个TTY,首先执行tty_do_read(), 然后调用keyboard_read()并将读入的字符交给函数in_process()来处理,如果是需要输出的字符,会被in_process()放入当前接受处理的TTY的缓冲区中,然后tty_do_write()会被接着执行,如果缓冲区中有数据,则被送入out_char显示出来.
注意区分用户进程和任务