目录
前言
我有一个梦想,那就是自己写一个操作系统……
嗯……《操作系统真象还原》是本好书,我的计划就是跟着这本书从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),那么你的优先级就可以提高。
这一部分我就草草略过了,内容很多,全是密密麻麻的字……
总结
时隔一个月左右,终于把第五章看完了……中间经历了期末复习,期末考试,课设和竞赛,没办法,这些事都是比自主学习的特权级更高的,明天开始进入第六章的学习,继续努力吧!