操作系统真象还原——第5章 从保护模式到内核

前言

我有一个梦想,那就是自己写一个操作系统……
嗯……《操作系统真象还原》是本好书,我的计划就是跟着这本书从0造一个操作系统……
前几章的内容,我们完成了从BIOS到MBR,从实模式到保护模式,这一章叫做《保护模式进阶,向内核迈进》,正如笔者说的,前面的大量笔墨花在了理论,从这一章开始,我们才算开始了真正的操作系统学习之旅。
那我们就开始吧!

5.1 获取物理内存容量

操作系统无时无刻不在和内存打交道,为了做好内存的管理,我们得先知道自己到底有多少物理内存才行。

5.1.1 学习Linux获取内存的方法

Linux获取内存容量的本质是调用BIOS中断0x15实现的,它有三个子功能:

  • EAX = 0xE820:遍历主机上全部内存(看上去是最厉害的……)
  • AX = 0xE201:分别检测低15MB和16MB~4GB的内存,最大支持4GB
  • AH = 0x88:最多检测64MB内存,实际内存超过也按照64MB返回(看上去是最弱的……)

接下来作者事无巨细地介绍了三大功能的具体细节,像这种东西,我一般都是草草略过,因为我知道看一遍也记不下来……
嗯,用的时候再去查就好了……
感觉学计算机就是需要这样……

5.1.2 实战内存容量检测

下面以功能最强大的0xE820为例,进行loader.S的修改:

%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

; construct gdt and its inner-descriptor
GDT_BASE: dd 0x00000000
		  dd 0x00000000

CODE_DESC: dd 0x0000FFFF
		   dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
				 dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007
			dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

total_mem_bytes dd 0	; save memory capacity

; gdt's pointer
gdt_ptr dw GDT_LIMIT
		dd GDT_BASE

ards_buf times 244 db 0 ; buffer_size
ards_nr dw 0 ; buffer_num

loader_start:

xor ebx, ebx
mov edx, 0x534d4150
mov di, ards_buf
.e820_mem_get_loop:
	mov eax, 0x0000e820
	mov ecx, 20
	int 0x15
	add di, cx
	inc word, [ards_nr]
	cmp ebx, 0
	jnz .e820_mem_get_loop

	mov cx, [ards_nr]
	mov ebx, ards_buf
	xor edx, edx
.find_max_mem_area:
	mov eax, [ebx]
	mov eax, [ebx + 8]
	add ebx, 20
	cmp edx, eax
	jge .next_ards
	mov edx, eax
.next_ards:
	loop .find_max_mem_area

.mem_get_ok:
	mov [total_mem_bytes], edx

; print string
	mov sp, LOADER_BASE_ADDR
	mov bp, loadermsg
	mov cx, 15
	mov ax, 0x1301
	mov bx, 0x001f
	mov dx, 0x1800
	int 0x10

; ========= ready to enter protection mode ? =========
; =========           1. open A20            =========
; =========           2. load gdt            =========
; =========           3. set pe = 1         =========

	;================ 1. open A20 ===============
	in al, 0x92
	or al, 0000_0010b
	out 0x92, al

	;================ 2. load GDT ===============
	lgdt [gdt_ptr]

	; =============== 3. set pe = 1 =============
	mov eax, cr0
	or eax, 0x00000001
	mov cr0, eax

	jmp dword SELECTOR_CODE:p_mode_start

[bits 32]
p_mode_start:
	mov ax, SELECTOR_DATA
	mov ds, ax
	mov es, ax
	mov ss, ax
	mov esp, LOADER_STACK_TOP
	mov ax, SELECTOR_VIDEO
	mov gs, ax

	mov byte [gs:160], 'P'	

jmp $

将新的loader安装到硬盘中,调试结果非常奇怪:
在这里插入图片描述
可以看到,显示的结果为0x1ef0000,并不是理想的0x02000000,这让我百思不得其解。。
通过检查,发现add eax [ebx + 8]写成了mov,也就是说0x1ef0000其实是内存的长度,而作者所谓的内存容量包含了内存基址+内存长度,也就是说,作者计算出来的内存容量,其实是内存的上界限……
这是为什么呢?经过一番思考,我觉得内存的分布结构应该是如下这样:
在这里插入图片描述
也就是说,通过e820探测到不同内存块之后,我们需要考虑整个内存的容量,也就是找到内存的上界限值,即最大内存的基址再加上最大内存本身长度值,等于原先定义好的32MB,验证成功。

;正确的代码
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

; construct gdt and its inner-descriptor
GDT_BASE: dd 0x00000000
		  dd 0x00000000

CODE_DESC: dd 0x0000FFFF
		   dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
				 dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007
			dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 59 dq 0
times 5 db 0

total_mem_bytes dd 0	; save memory capacity

; gdt's pointer
gdt_ptr dw GDT_LIMIT
		dd GDT_BASE

ards_buf times 244 db 0 ; buffer_size
ards_nr dw 0 ; buffer_num

SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

loader_start:

xor ebx, ebx
mov di, ards_buf
.e820_mem_get_loop:
	mov eax, 0x0000e820
	mov edx, 0x534d4150
	mov ecx, 20
	int 0x15
	add di, cx
	inc word [ards_nr]
	cmp ebx, 0
	jne .e820_mem_get_loop

	mov cx, [ards_nr]
	mov ebx, ards_buf
	xor edx, edx
.find_max_mem_area:
	mov eax, [ebx]
	add eax, [ebx+8]
	add ebx, 20
	cmp edx, eax	; if ebx >= eax: continue, else ebx = eax
	jge .next_ards
	mov edx, eax
.next_ards:
	loop .find_max_mem_area
	jmp .mem_get_ok

.mem_get_ok:
	mov [total_mem_bytes], edx

; ========= ready to enter protection mode ? =========
; =========           1. open A20            =========
; =========           2. load gdt            =========
; =========           3. set pe = 1         =========

	;================ 1. open A20 ===============
	in al, 0x92
	or al, 0000_0010b
	out 0x92, al

	;================ 2. load GDT ===============
	lgdt [gdt_ptr]

	; =============== 3. set pe = 1 =============
	mov eax, cr0
	or eax, 0x00000001
	mov cr0, eax

	jmp dword SELECTOR_CODE:p_mode_start

[bits 32]
p_mode_start:
	mov ax, SELECTOR_DATA
	mov ds, ax
	mov es, ax
	mov ss, ax
	mov esp, LOADER_STACK_TOP
	mov ax, SELECTOR_VIDEO
	mov gs, ax

	mov byte [gs:160], 'P'	

jmp $

5.2 内存分页

为什么需要分页?

主要还是解决分段中存在的碎片等问题。

一级页表

一句话概括:将地址拆成高20位和低12位,高20位通过硬件进行查表获得实际的物理地址,然后再加上低12位获得真正的物理地址。
在这里插入图片描述
在这一段中作者再次提及了平坦模型,所谓平坦模型,就是相对于多段模型的概念,在32位CPU中,我们可以访问的内存大小为4GB,那么对于平坦模型的概念就是整个4GB看成一个段,它的基地址就是从0开始,也就是说,一个偏移量就可以对应一个确定的地址。
因为偏移量共有12位,所以一个标准页的大小就是 2 12 2^{12} 212,也就是4KB。

二级页表

二级页表主要是为了解决动态创建页表的问题。
一级页表是每个进程使用一个页表,二级页表是每个进程使用一个页目录表,每个页目录表项又对应一张页表。
在这里插入图片描述
接下来作者介绍了页目录项和页表项,除了物理地址之外,还有很多的控制位,存在位、访问位、读写位……这些到时候需要再回来看。
启动分页机制,就是完成以下三件事情:

1. 准备好页目录表和页表
2. 将页表地址写入专门存放页目录项的基址寄存器cr3
3. 寄存器cr0的PG位置1,也就是控制操作系统进入内存分页机制

如何设计一个页表

一言概括,学习Linux的做法,0-3GB的虚拟地址空间分给用户进程,3-4GB的虚拟地址空间分给操作系统。

分页机制的代码实现

首先修改下boot.inc里面的配置如下:

PAGE_DIR_TABLE_POS equ 0x100000	; 页目录表的物理地址
; ------------- page table property -------------
PG_P equ 1b	; 操作系统在处理完缺页中断之后将P为置1,被虚存管理置换进外存时置0,可以理解为有效位
PG_RW_R equ 00b	; 该内存只可读
PG_RW_W equ 10b ; 该内存可写
PG_US_S equ 000b	; 该内存不能被特权级为3的任务访问
PG_US_U equ 100b	; 该内存可以被任何特权级的任务访问

下面是分页机制的实现:

;-------------- 创建页目录及页表 ---------------
setup_page:
; 先将页目录占用的空间逐字节清零
	mov ecx, 4096
	mov esi, 0
.clear_page_dir:
	mov byte [PAGE_DIR_TABLE_POS + esi], 0	; PAGE_DIR_TABLE是页表指针,这个宏定义在include.inc中
	inc esi
	loop .clear_page_dir
; 创建页目录项(PDE)
.create_pde:
	mov eax, PAGE_DIR_TABLE_POS
	add eax, 0x1000	; 此时的eax对应第一个页表的位置
	mov ebx, eax	; ebx是第一个页表的位置
	or eax, PG_US_U | PG_RW_W | PG_P ; 这是一个用户属性的页表
	mov [PAGE_DIR_TABLE_POS], eax ; 第一个目录项
	mov [PAGE_DIR_TABLE_POS + 0xc00], eax	; 0xc00表示第768个页表占用的目录项
	; 该项划分出3G和1G的虚存空间大小,该项往上属于内核,该项往下属于用户进程
	sub eax, 0x1000
	mov [PAGE_DIR_TABLE_POS +4092], eax	; 将最后一个目录项指向页目录表自己的地址,应该是为了循环遍历

; 接下来创建页表项
	mov ecx, 256	; 1M低端内存 / 4K页大小 = 256
	mov esi, 0
	mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:
	mov [ebx + esi * 4], edx
	add edx, 4096
	inc esi
	loop .create_pte	; 循环256次建立,最开始的1MB内存的虚拟地址等于物理地址

; 创建内核其他页表的PDE
	mov eax, PAGE_DIR_TABLE_POS
	add eax, 0x2000
	or eax, PG_US_U | PG_RW_W | PG_P
	mov ebx, PAGE_DIR_TABLE_POS
	mov ecx, 254	; 最开始的一张表在前面已创建好,最后一张循环指向表头
	mov esi, 769	; 从769-1022
.create_kernel_pde:
	mov [ebx + esi * 4], eax
	inc esi
	add eax, 0x1000
	loop .create_kernel_pde
	ret	; 这一段和前面大同小异

为什么低端1M内存,也就是我们操作系统的内核,既要放在表头的用户区,又要放在内核空间呢,我的理解是为了实现内存空间的共享。
接下来就是正式启用分页的三部曲:

; 创建页目录和页表并初始化页内存位图
call setup_page	; 刚才编写的初始化函数

; 将描述符表地址及偏移量写入gdt_ptr备用
sgdt [gdt_ptr]

; 将gdt中显存段描述符中的段基址 + 0xc0000000
mov ebx, [gdt_ptr + 2]	; gdt的结构是前2位是偏移量,后四位是基址,现在先把基址取出来
or dword [ebx + 0x18 + 4], 0xc0000000 ; 0x18是因为显存段是第三段,每段8字节,所以加24
; 4指的是写入段基址的最高1字节

; 将gdt的基址加上0xc0000000使其成为内核所在的高地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ; 将栈指针同样映射到内核地址

; 第二步,将页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

; 第三步:打开cr0的pg位(第31位),开启分页机制
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
; 开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr]
mov byte [gs:160] 'v'

jmp $

接下来就是验证环节:
在这里插入图片描述
可以看到gdt的基址被修改到了0xc的内核区,至于比900多了3,这是因为loader.S最开始的跳转需要三个字节所导致,然后,第三个描述符——视频段,也处在内核区了,一切如预期一样良好……

5.3 加载内核

首先作者是详尽地介绍了elf,所谓elf,全称是Executable and Linkable Format,可执行链接格式,应该是和Windows中的PE(Portable Executable)是对等的,关于具体介绍,这里不再详细阐述,一言概之,elf文件是编译文件和进程的中间环节,它类似于C的风格,包含了文件头和文件体。
接下来写了一个最简单的内核C代码(以后代码主要是写C了,终于不用苦逼地写汇编了555):

int main(void) {
	while(1);
	return 0;
}

将它转换成二进制文件写入内核之中。
接下来loader.S完成两件事儿:加载内核并初始化。(刚说完不用写汇编就开始写汇编了)

; ------------- 加载kernel ---------------------
mov eax, KERNEL_START_SECTOR
mov ebx, KERNEL_BIN_BASE_ADDR
mov ecx, 200	; 读入的扇区数
call rd_disk_m_32
; 置于创建页表之前

加载内核的代码如上,主要是给寄存器赋上关于内核的信息,然后进入加载函数,接下来是初始化内核:

; -------- 将kernel.bin中的segment拷贝到编译的地址 --------
kernel_init:
	xor eax, eax
	xor ebx, ebx
	xor ecx, ecx
	xor edx, edx

	mov dx, [KERNEL_BIN_BASE_ADDR + 42]	; dx读取程序头大小
	mov ebx, [KERNEL_BIN_BASE_ADDR + 42]	; ebx读取程序头偏移量
	add ebx, KERNEL_BIN_BASE_ADDR
	mov cx, [KERNEL_BIN_BASE_ADDR + 44]	; cx读取程序头的个数

.each_segment:
	cmp byte [ebx +0], PT_NULL
	je .PTNULL	; 说明该程序头未被使用
	
	push dword [ebx + 16]	; 压入文件大小
	mov eax, [ebx + 4]
	add eax, KERNEL_BIN_BASE_ADDR	; 压入段物理地址
	push eax
	push dword [ebx + 8]	; 压入p_vaddr,目的地址
	call mem_cpy
	add esp, 12	; 清空栈
.PTNULL:
	add ebx, edx
	loop .each_segment
	ret

; ------------- 逐字节拷贝 mem_cpy(dst, src, size) --------------
mem_cpy:
	cld
	push ebp
	mov ebp, esp
	push ecx
	
	mov edi, [ebp + 8]
	mov esi, [ebp + 12]
	mov ecx, [ebp + 16]
	rep movsb	; 逐字节拷贝
	
	; 恢复环境
	pop ecx
	pop ebp
	ret

写完这些,5.3就接近尾声了,总结起来,就是用loader引导内核完成初始化,包括内核映像的建立,内核栈位置的变化等……但是这一part没有验证部分,难免有些不安,算了,如果出问题的话再回来看吧。

5.4 特权级

特权级这一部分主要就是概念了,特权级结构呈环状,简单地说,等级数值上越小,权力越大。

TSS是什么

一言概之,TSS是记录任务的数据结构,所谓任务,就是脱离了操作系统的进程,在没有操作系统的情况下,任务可以独立执行。TSS中记录了0,1,2三个级别的目标栈选择子和偏移量,除调用返回外,只能从低特权级转向高特权级,这就是为什么TSS无需记录3特权级栈的原因。

特权级究竟是什么

特权级通过CPL(Current Privilege Level),它任意时刻都存储在代码段寄存器的CS的RPL部分中。

什么是门

书中的解释非常棒,门就是蹦床,如果你够到了蹦床(门的DPL),那么你的优先级就可以提高。
这一部分我就草草略过了,内容很多,全是密密麻麻的字……


总结

时隔一个月左右,终于把第五章看完了……中间经历了期末复习,期末考试,课设和竞赛,没办法,这些事都是比自主学习的特权级更高的,明天开始进入第六章的学习,继续努力吧!

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值