操作系统实现学习笔记(上)

  • 有能力的同学可以直接先看着两个脚本对系统的创建方式有个初步认识.当然是直接建在虚拟机上的,比较简单.
  • Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!Makefile

C_SOURCES = $(shell find . -name "*.c")
C_OBJECTS = $(patsubst %.c, %.o, $(C_SOURCES))
S_SOURCES = $(shell find . -name "*.s")
S_OBJECTS = $(patsubst %.s, %.o, $(S_SOURCES))

CC = gcc
LD = ld
ASM = nasm

C_FLAGS = -c -Wall -m32 -ggdb -gstabs+ -nostdinc -fno-builtin -fno-stack-protector -I include
LD_FLAGS = -T scripts/kernel.ld -m elf_i386 -nostdlib
ASM_FLAGS = -f elf -g -F stabs

all: $(S_OBJECTS) $(C_OBJECTS) link update_image

.c.o:
@echo 编译代码文件 $< ...
$(CC) $(C_FLAGS) $< -o $@

.s.o:
@echo 编译汇编文件 $< ...
$(ASM) $(ASM_FLAGS) $<

link:
@echo 链接内核文件...
$(LD) $(LD_FLAGS) $(S_OBJECTS) $(C_OBJECTS) -o hx_kernel

.PHONY:clean
clean:
$(RM) $(S_OBJECTS) $(C_OBJECTS) hx_kernel

.PHONY:update_image
update_image:
sudo mount floppy.img /mnt/kernel
sudo cp hx_kernel /mnt/kernel/hx_kernel
sleep 1
sudo umount /mnt/kernel

.PHONY:mount_image
mount_image:
sudo mount floppy.img /mnt/kernel

.PHONY:umount_image
umount_image:
sudo umount /mnt/kernel

.PHONY:qemu
qemu:
qemu -fda floppy.img -boot a

.PHONY:bochs
bochs:
bochs -f tools/bochsrc.txt

.PHONY:debug
debug:
qemu -S -s -fda floppy.img -boot a &
sleep 1
cgdb -x tools/gdbinit
  • kernel.ld
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*
* kernel.ld -- 针对 kernel 格式所写的链接脚本
*/

ENTRY(start)
SECTIONS
{
/* 段起始位置 */

. = 0x100000;
.text :
{
*(.text)
. = ALIGN(4096);
}

.data :
{
*(.data)
*(.rodata)
. = ALIGN(4096);
}

.bss :
{
*(.bss)
. = ALIGN(4096);
}

.stab :
{
*(.stab)
. = ALIGN(4096);
}

.stabstr :
{
*(.stabstr)
. = ALIGN(4096);
}

/DISCARD/ : { *(.comment) *(.eh_frame) }
}

一个操作系统的实现阅读笔记

​ 实模式下,段值还是可以看作地址的一部分, 段值为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中寻找相应描述符.

进入保护模式的步骤:

  1. 准备GDT
  2. 用lgdt加载gdtr
  3. 打开A20
  4. 置cr0的PE位
  5. 跳转,进入保护模式

$ 表示当前行被汇编后的地址, $$表示程序被汇编后的开始地址.

“一致”: 当转移的目标是一个特权级更高的一致代码段,当前的特权级会被延续下去,而向特权级更高的非一致代码段的转移会引起常规保护错误,除非使用调用门或者任务门. 当目标代码的特权级低的话,无论它是不是一致代码段,都不能通过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会被区别对待:

  1. 数据段,调用门,TSS规定的是最低特权级, 即 CPL <= DPL
  2. 非一致代码段规定的是特权级, 即 CPL = DPL (and RPL小于DPL)
  3. 一致代码段和通过调用门访问的非一致代码段规定的是最高特权级, 即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在整个过程中做的工作

  1. 根据目标代码段的DPL(新CPL)从TSS中选择应该切换至那个ss和esp
  2. 从TSS中读取新的ss和esp,在整个过程中如果发现ss,esp或者TSS界限错误都会导致无效TSS异常.
  3. 对ss描述符进行检验,如果发生错误,同样产生#TS异常.
  4. 暂时性地保存当前ss和esp的值.
  5. 加载新的ss和esp.
  6. 将刚保存的ss和esp值压入新栈.
  7. 从调用者堆栈中将参数复制到被调用者堆栈中,复制参数的数目由调用门中Param Count一项决定.如果为0则不会复制
  8. 将当前的cs和eip压栈.
  9. 加载调用门中制定的新的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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
; 启动分页机制 --------------------------------------------------------------
SetupPaging:
; 根据内存大小计算应初始化多少PDE以及多少页表
xor edx, edx
mov eax, [dwMemSize]
mov ebx, 400000h ; 400000h = 4M = 4096 * 1024, 一个页表对应的内存大小
div ebx
mov ecx, eax ; 此时 ecx 为页表的个数,也即 PDE 应该的个数
test edx, edx
jz .no_remainder
inc ecx ; 如果余数不为 0 就需增加一个页表
.no_remainder:
push ecx ; 暂存页表个数

; 为简化处理, 所有线性地址对应相等的物理地址. 并且不考虑内存空洞.

; 首先初始化页目录
mov ax, SelectorPageDir ; 此段首地址为 PageDirBase
mov es, ax
xor edi, edi
xor eax, eax
mov eax, PageTblBase | PG_P | PG_USU | PG_RWW
.1:
stosd
add eax, 4096 ; 为了简化, 所有页表在内存中是连续的.
loop .1

; 再初始化所有页表
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
pop eax ; 页表个数
mov ebx, 1024 ; 每个页表 1024 个 PTE
mul ebx
mov ecx, eax ; PTE个数 = 页表个数 * 1024
xor edi, edi
xor eax, eax
mov eax, PG_P | PG_USU | PG_RWW
.2:
stosd
add eax, 4096 ; 每一页指向 4K 的空间
loop .2

mov eax, PageDirBase
mov cr3, eax
mov eax, cr0
or eax, 80000000h
mov cr0, eax
jmp short .3
.3:
nop

ret
; 分页机制启动完毕 ----------------------------------------------------------

分页机制:

利用中断15h可以检测机器的内存.

  1. eax设为0E820h, 调用后为’SMAP’
  2. ebx放置后续值(为等到下一个地址描述符所需要的后续值)
  3. es:di指向一个地址范围描述符结构ARDS
  4. ecx位BIOS填充在地址范围描述符的字节数量20字节.
  5. ead 0534D4150h(‘SMAP’),BIOS使用此标志对调用者请求的系统映像信息进行校验, 这些信息被BIOS放置到es:di所指向的结构中

ARDS(Address Range Descriptor Structure)的结构

1
2
3
4
5
6
偏移		名称			意义
0 BaseAddrLow 基地址的低32位
4 BaseAddrHigh 基地址的高32位
8 LengthLow 长度的低32位
12 LengthHigh 长度的高32位
16 Type 这个地址范围的地址类型

分页机制可以达到的效果: 先执行某线性地址处的模块, 然后通过改变cr3来转换地址映射关系,在执行同一个线性地址处的模块. 因地址映射已经改变, 所以两次输出不同.

中断

保护模式下中断机制变化, 中断单向量表被IDT代替, IDT中断描述符, 分为中断门,陷阱门,任务门, 作用是将每一个中断向量和一个描述符对应起来.

中断分为不可屏蔽中孤单(NMI)和可屏蔽中断, 分别由CPU的两根引脚NMI和INTR来接收, NMI中断对应的中断向量号通常为2, 其中可屏蔽中断与CPU的关系是通过可编程中断控制器8259A建立起来的. 对他的设置通过相应端口写入特定ICW实现.

OCW有三个,通常用于以下情况:

  1. 屏蔽或打开外部中断; 向8259A写入OCW1. 其实是写入了IMR(主动屏蔽器)
  2. 发送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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
;***************************************************************
; 内存看上去是这样的:
; ┃ ┃
; ┃ . ┃
; ┃ . ┃
; ┃ . ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; ┃■■■■■■Page Tables■■■■■■┃
; ┃■■■■■(大小由LOADER决定)■■■■┃
; 00101000h ┃■■■■■■■■■■■■■■■■■■┃ PageTblBase
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 00100000h ┃■■■■Page Directory Table■■■■┃ PageDirBase <- 1M
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; F0000h ┃□□□□□□□System ROM□□□□□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; E0000h ┃□□□□Expansion of system ROM □□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; C0000h ┃□□□Reserved for ROM expansion□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃ B8000h ← gs
; A0000h ┃□□□Display adapter reserved□□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; 9FC00h ┃□□extended BIOS data area (EBDA)□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 90000h ┃■■■■■■■LOADER.BIN■■■■■■┃ somewhere in LOADER ← esp
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 80000h ┃■■■■■■■KERNEL.BIN■■■■■■┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 30000h ┃■■■■■■■■KERNEL■■■■■■■┃ 30400h ← KERNEL 入口 (KernelEntryPointPhyAddr)
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃ ┃
; 7E00h ┃ F R E E ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 7C00h ┃■■■■■■BOOT SECTOR■■■■■■┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃ ┃
; 500h ┃ F R E E ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; 400h ┃□□□□ROM BIOS parameter area □□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇┃
; 0h ┃◇◇◇◇◇◇Int Vectors◇◇◇◇◇◇┃
; ┗━━━━━━━━━━━━━━━━━━┛ ← cs, ds, es, fs, ss
;
;
; ┏━━━┓ ┏━━━┓
; ┃■■■┃ 我们使用 ┃□□□┃ 不能使用的内存
; ┗━━━┛ ┗━━━┛
; ┏━━━┓ ┏━━━┓
; ┃ ┃ 未使用空间 ┃◇◇◇┃ 可以覆盖的内存
; ┗━━━┛ ┗━━━┛
;
; 注:KERNEL 的位置实际上是很灵活的,可以通过同时改变 LOAD.INC 中的
; KernelEntryPointPhyAddr 和 MAKEFILE 中参数 -Ttext 的值来改变。
; 比如把 KernelEntryPointPhyAddr 和 -Ttext 的值都改为 0x400400,
; 则 KERNEL 就会被加载到内存 0x400000(4M) 处,入口在 0x400400。
;
; ------------------------------------------------------------------------

如图, 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.

进程开始之前的核心内容和关系可以分为三个部分:

  1. 进程表和GDT. 进程表内的LDT selector对应GDT中的一个描述符,而这个描述符所指向的内存空间就在这个进程表内.
  2. 进程表和进程.
  3. GDT和TSS. GDT中需要一个描述符对应TSS, 并且初始化它.

进程表需要初始化的主要有3部分: 寄存器, LDT Selector, LDT.

多进程

中断重入 , 因为CPU在相应中断的过程中会自动关闭中断, 需要人为地打开中断, 加入sti指令, 然后, 要保证中断处理过程足够长, 以至于在它完成之前会有下一个中断产生, 在中断处理例程中调用一个延迟函数. 即中断嵌套.

先列一下多进程的步骤:

  1. 在task_table中增加一项,即多一项任务内容
  2. 让NR_TASKS加一, 即进程数量加一
  3. 定义任务堆栈.
  4. 修改STACK_SIZE_TOTAL
  5. 添加新任务执行体的函数声明

这里再补充一下进程切换的内容有助于理解:

  • clockhander:

一个操作系统的运转过程(例子):

计数器的工作原理: 有一个输入频率如1193180Hz,在每一个时钟周期(CLK cycle),计数器值会减一,当减到0时,就会触发一个输出,如果计数器是16位的,则最大值为65535,默认的时钟中断发生频率就是1193180/65536 约等于 18.2Hz. 我们可以通过改变计数器的计数值来控制频率(时间间隔).

shell的实现(tty)

屏幕显示字符常用的汇编语句是

1
2
3
mov ah, 0Ch ; 0000:黑底 1100:红字
mov al, 'P'
mov [gs:edi], ax

其实看张图就明白, 字符对应的字节和位定义如下:

tty的任务框架:

TTY任务开始运行时, TTY都被初始化, nr_current_console会被赋值为0,然后持续循环.而对于每一个TTY,首先执行tty_do_read(), 然后调用keyboard_read()并将读入的字符交给函数in_process()来处理,如果是需要输出的字符,会被in_process()放入当前接受处理的TTY的缓冲区中,然后tty_do_write()会被接着执行,如果缓冲区中有数据,则被送入out_char显示出来.

注意区分用户进程和任务