一、获取物理内存容量
1.利用BIOS中断0x15子功能0xe820获取内存
- 0xe820能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS就按照类型属性来划分这片系统内存,所以这种查询呈迭代式。每次BIOS只返回一种类型的内存信息,直到将所有内存类型返回完毕。
- 地址范围描述符:内存信息的内容是用地址范围描述符来描述的,用来存储这种描述符的结构称为地址范围描述符ARDS
- ARDS中的字段大小都是4字节,共5个字段,所以此结构的大小为20字节。ARDS结构中用64位宽度的属性来描述这段内存的基地址(起始地址)及其长度,所以表中的基地址和长度都分为低32位和高32位。
- 每次int 0x15后,BIOS就返回一个ARDS结构的数据。
- type字段:描述这段内存的类型,说明其用途,即可不可以被操作系统使用。
- 0x15的参数
- ECX寄存器和ES:DI寄存器,是典型的“值-结果”型参数,即调用方提供了两个变量作为被调函数的参数——一个是缓冲区指针,一个是缓冲区大小
- 此中断调用过程:
- 填写好“调用前输入”中的寄存器
- 执行中断调用int 0x15
- 在CF位为0时,“返回后输出”中对应的寄存器便是对应的结果
2.利用BIOS中断0x15子功能0xe801获取内存
- 0xe801最大可识别4GB内存,检测到的内存分别存放在两组寄存器中:
- 低于15MB的内存以1KB为单位记录,单位数量在寄存器ax和cx中记录(ax=cx),所以15MB空间下的实际内存容量 = ax*1024
- 16MB-4GB是以64KB为单位记录的,单位数量在寄存器bx和dx中记录(bx=dx),所以16MB以上空间的实际内存容量 = bx* 64*1024
- 此中断调用过程:
- 将ax寄存器写入0xe801
- 执行中断调用int 0x15
- 在CF = 0时,“返回后输出”中对应的寄存器便是对应的结果
3.利用BIOS中断0x15子功能0xe88获取内存
- 只能识别到最大64MB内存,即使内存容量大于64MB,也只能显示63MB。因为此中断只显示1MB以上的内存,不包含这1MB,所以在使用时记得加上这1MB。
- 此中断调用过程:
- 将ax寄存器写入0x88
- 执行中断调用int 0x15
- 在CF = 0时,“返回后输出”中对应的寄存器便是对应的结果
4. -------------------------代码-----------------------------------
- /home/lily/OS/boot/loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; loader在保护模式下的栈指针地址,esp
; 构建GDT及其内部描述符, 每个描述符8个字节, 拆分为高低各4字节(32位)
GDT_BASE: dd 0x00000000 ; 第0个描述符,不可用
dd 0x00000000
CODE_DESC: dd 0x0000ffff ; 低32位31~16位为段基址15~0位, 15~0位为段界限15~0位
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000ffff ; 数据段(栈段)描述符
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ; 0xB8000 到0xBFFFF为文字模式显示内存 段界限:limit=(0xbffff-0xb8000) / 4k=0x7
dd DESC_VIDEO_HIGH4 ; 0xB
GDT_SIZE: equ $ - GDT_BASE ; 当前位置减去GDT_BASE的地址 等于GDT的大小
GDT_LIMIT: equ GDT_SIZE - 1 ; SIZE - 1即为最大偏移量
times 60 dq 0 ; 预留60个 四字型 描述符空位, 用于后续扩展
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 段选择子: 低3位为TI RPL状态, 其余为描述符索引值
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
;total_mem_bytes用于存储获取到的内存容量,以字节为单位,此位置比较好记
;当前偏移loader.bin文件头0x200字节
;loader.bin的加载地址是0x900
;故total_mem_bytes内存中的地址是0xb00
;将来在内核中我们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;gdt指针, 前2字节为gdt界限, 后4字节为gdt起始地址(共48位)
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes4+gdt_ptr6+ards_buf244+ards_nr2,共256字节
ards_buf times 244 db 0 ;ards_buf为缓冲区地址,一个ARDS是20字节,具体多大取决于有多少个ARDS结构,所以先分配224字节看看,db表示一个或多个字节
ards_nr dw 0 ;用来记录ards结构体数量,dw是16位,一个字
loader_start:
;int 15h eax = 0000e820h,edx = 534d4150h('SMAP')获取内存布局
xor ebx,ebx ;第一次调用时,ebx的值要为0
mov edx,0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di,ards_buf ;ards结构缓冲区,es:di存放缓冲区地址,而es在mbr中已经赋值
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax,0x0000e820 ;执行int 0x15后,eax的值变为0x534d4150,所以每次执行int前要更新为子功能号
mov ecx,20 ;ARDS地址范围描述符结构大小是20字节
int 0x15
;执行int 0x15后,寄存器eax ebx ecx都会更新;eax的值由子功能号->SAMP字符,ebx为新的后续值,ecx为实际写入缓冲区的字节数
;其中ebx不用管,而eax和ecx在每次调用前都要调整为正确的输入参数,所以放在了循环体中
;接下来每得到一个ARDS结构,就将di增加一个ARDS大小(20字节),以便存放下一个ARDS结构。
;然后将ards_nr +1,以记录ARDS的数量。用于后面遍历所有的内存块,找出最大内存
jc .e820_failed_so_try_e801 ;若cf=1,则有错误发生,尝试0xe801子功能
add di,cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量,inc表示加一,将ards_nr处的内容+1
cmp ebx,0 ;若ebx为0且cs不等于1,则说明ards全部返回,当前已是最后一个,ebx为后续的新值
jnz .e820_mem_get_loop ;n是not,z是zero;意思是当判断的值不等于0时就跳转
;在所有ards结构中找出(base_add_low + length_low)的最大值,即找出最大内存块。之后便直接跳转到.mem_get_ok,将此容量数写入total_mem_bytes中
mov cx,[ards_nr] ;记录此时ards的数量
;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx,ards_buf ;将ards放入ebx中
xor edx,edx ;edx用于记录最大的内存容量,在此清零,xor为异或,相当于int max = 0
.find_max_mem_area: ;无需判断type是否为1,最大的内存块一定是可用的
mov eax,[ebx] ;base_add_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向缓冲区中下一个ARDS结构
cmp edx,eax ;比较edx和eax,本质是edx-eax
;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jpe .next_ards ;奇偶位相等则跳转,若1出现次数为偶数则跳转
mov edx,eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok
;----------------int 0x15h ax=e801h获取内存大小,最大支持4G------------
;返回后,ax和cx的值一样,以KB为单位;bx和dx的值一样,以64KB为单位
;在ax和cx寄存器中为最低16MB,在bx和dx寄存器中为16MB到4GB
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法,jump if carry根据CF位判断是否跳转
;1.先算出低15MB的内存 = 0xF0_0000
;ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
;ax = cx中存储调用int 0x15后的返回值,以KB为单位,假设返回值为nMB,则ax的值为n*2^10
mov cx,0x400 ;cx用作乘数,cx = 2^10
mul cx ;ax = n*2^10*2^10 = n*2^20 = nM(B),存放在[dx+ax]中,即:[0xn0+0x0000]
shl edx,16 ;edx左移16位(2字节),edx原本为0x10,现在为0xn0_0000
and eax,0x0000FFFF ;把eax的高16位清空,低16位保留
or edx,eax ;将ax*cx的积完全移入edx中,edx = nMB
add edx,0x10_0000 ;ax只能保存15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份,即esi = (n+1)MB
;2.再将16MB以上的内存转换为byte为单位
;寄存器bx和dx中以64KB为单位的内存数量
;bx = dx中保存了16MB以上的返回值,以64KB为单位,若返回值为nMB,则bx中存储的值为n/64K = n*2^4
xor eax,eax ;将eax清零
mov ax,bx ;ax = bx = n*2^4用作乘数
mov ecx,0x1_0000 ;0x10000十进制为64KB = 2^16
mul ecx ;32位乘法,默认的被乘数为eax,积为64位;高32位存入edx,低32位存入eax,即edx+eax = n*2^20 = nMB,即[0x0:n00000]
add esi,eax ;此方法只能测出4GB一下的内存,所以32位的eax就足够,edx肯定为0,只加eax即可
mov edx,esi ;edx为内存总大小
jmp .mem_get_ok
;---------------int 0x15h=0x88获取内存大小,只能获取64MB以内-------------
.e801_failed_so_try88:
mov ah,0x88
int 0x15
jc .error_hlt ;jc条件跳转
and eax,0x0000FFFF
mov cx,0x400 ;16位乘法,被乘数是ax,积为32位。积的高16位在dx中,低16位在ax中
mul cx
shl edx,16 ;左移16位(2字节),把dx移到高16位
or edx,eax ;把积的低16位组合到edx,为32位的积
add edx,0x10_0000 ;0x88子功能只会返回1MB以上的内存,故实际内存要加1MB
.mem_get_ok:
mov [total_mem_bytes],edx ;将内存换以byte为单位后存入total_mem_bytes处
; --------------------------------- 设置进入保护模式 -----------------------------
; 1 打开A20 gate
; 2 加载gdt
; 3 将cr0 的 pe位(第0位)置1
; ----------------- 打开A20 ----------------
in al, 0x92 ; 端口号0x92 中的第1位变成 1 即可
or al, 0000_0010B
out 0x92, al
; ----------------- 加载GDT ----------------
lgdt [gdt_ptr]
; ----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
; -------------------------------- 已经打开保护模式 ---------------------------------------
jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,因为后面的指令为32位
.error_hlt: ; 出错则挂起
hlt ; 处理器暂停, 直到出现中断或复位信号才继续
[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' ;向显存中第二行首字符的位置写入P,因为一行80个字符(0-79)
jmp $
- /home/lily/OS/boot/mbr.S
jmp LOADER_BASE_ADDR + 0x300 //唯一需要修改的地方
- 运行代码
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S
dd if=/home/lily/OS/boot/mbr.bin of=/home/lily/bochs/hd60M.img bs=512 count=1 conv=notrunc
dd if=/home/lily/OS/boot/loader.bin of=/home/lily/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
4. 检查megs
cat -n bochsrc.disk
- 运行代码
bin/bochs -f bochsrc.disk
//在bochs目录下运行
- 在控制台用xp命令查看该地址
ctrl + c
xp 0xb00
二、启用内存分页机制,畅游虚拟空间
1.分页机制
- 分页机制的核心思想:解除线性地址和物理地址的一一对应关系,使线性地址连续,而物理地址不连续。使连续的线性地址可以与任意物理内存地址相关联。
- 内存寻址的核心仍然是“段基址+段内偏移地址”,这两个地址的相加求和是由CPU部件自动完成的,形成线性地址(绝对地址)。因此,分页机制只能在分段后进行。
- 如果分页,则线性地址=物理地址;若不分页,则线性地址=虚拟地址。此虚拟地址对应的物理地址需要在页表中查找,这部分由页部件自动完成。
- 分页机制的作用:
- 将线性地址转换为物理地址
- 用大小相等的页代替大小不等的段
- 分页机制的加载过程:
- 进程的地址转换过程:从线性空间到虚拟空间再到物理地址空间
- 改写虚拟空间:每加载一个进程,OS都会在虚拟空间中寻找可用空间分配内存段,此虚拟空间可以是页表或某种数据结构。此阶段是逻辑的,没有真正写入物理内存。此时,代码段和数据段被OS拆分为以页为单位的小内存块。
- 改写物理内存:OS为虚拟内存分配真正的物理内存页,它查找物理内存中可用的页,然后在页表中登记这些物理页地址,这样就完成了虚拟地址->物理地址的映射。
2.一级页表
- 页表
- 作用:存储线性地址->物理地址的映射
- 结构:N行1列的表格。
- 页表项:页表中的每一行(一个单元格)称为页表项,其大小为4字节。作用是存储物理内存地址。
- 当访问一个线性地址时,实际上是在访问页表项中所记录的物理内存地址。
- 页尺寸的选择:
- 32位地址表示4GB空间,因此,内存块数*内存块大小 = 4GB
- CPU中采取的页大小为4KB(物理内存),所以4GB的地址空间被划分为4GB/4KB = 1M个页。所以页表中有1M个页表项。(页表大小为1M*4B = 4MB)
- 因为是以4KB(2^12B)为页大小,所以页表项中物理地址的后3位都是一样的。【内存以字节为单位】
- 页:地址空间的计量单位,并不是专属于物理地址或线性地址,只要是4KB的地址空间都可以称为一页。
- 页表的使用
- 页表项:11-0位表示页内寻址(页内偏移量),31-12位表示页的数量(第几页),这样可以访问到任何一页的任何1字节的内存。
- 如何用线性地址找到页表项:
① 线性地址的高20位是页表项的索引,每个页表项是4字节,所以页表项*4B是才是页表物理地址的字节偏移量。用cr3寄存器的值(页表物理地址)+页表物理地址偏移量 = 页表项物理地址。
② 线性地址的低12位 + 页表项物理地址 = 最终物理地址 - 页部件:CPU中集成的专门根据分页,用线性地址寻找物理地址的部件。
- 页表项:11-0位表示页内寻址(页内偏移量),31-12位表示页的数量(第几页),这样可以访问到任何一页的任何1字节的内存。
3.二级页表
- 目前的现代操作系统,包括我们的OS,都是使用的二级页表。
- 一级页表的缺点:
- 一级页表最多容纳1M个页,一个页表项4B,则页表大小为4MB。
- 一级页表的所有页表项必须提前建好,因为OS要占用4GB虚拟地址空间的高1GB,用户进程要占用低3GB。(页表项需要一次性建好,不能动态创建页表项)
- 每个进程都要自己的页表,占用空间太大。
- 二级页表:
- 无论几级页表,页大小都是4KB,所以4GB的空间最多容纳1M个页。一级页表是将1M个页放在一张页表中,而二级页表是将其以1K为单位放在1K个页表中。因为页表项为4B,因此页表大小正好为4KB(标准页大小)。
- 页目录表:页目录表用来存储这1K个页表,每个页表的物理地址都在页目录表中以“页目录项”形式存储,页目录项大小 = 页表项大小,所以页目录表也是4KB大小。
- 页目录项的构成:31-22位用来在页目录表中定位一个页表,21-12位用来在页表中定位具体的物理页,11-0位用于页内偏移量。
- 页表的存储:因为页目录表和页表都是4KB(一页大小),所以由OS在物理内存中分配一物理页存储。(图中加粗的黑线就是页目录表和页表在物理内存中的存储,不需要连续)
- 4KB的好处:
- 4K为0x1000,计算简单
- 页目录表和页表都是一页大小,在物理内存中存储简单(分配几页存储)
- 使得地址是4K的整数倍,地址的低12位为0,剩下这12位用来记录页的其他特征。
- 二级页表的寻址过程:
- 虚拟地址的高10位乘4(页目录项为4B),作为页目录表的偏移地址,再加上页目录表的物理地址 = 页目录项的物理地址。读取该页目录项,得到页表的物理地址。
- 虚拟地址的中间10位乘4,作为页表的偏移地址,再加上页表的物理地址 = 页表项的物理地址。读取该页表项,得到物理页的物理地址。
- 虚拟地址的低12位作为页内偏移地址,用第2步的物理地址 + 页内偏移地址 = 最终物理地址。
- 页目录项和页表项
- 因为一页为4KB,所以地址都是4K的整数倍,地址的低12位都是0,所以只需要记录物理地址的第高20位即可。这样省下来的12位可以用来添加其他属性。
- 因为一页为4KB,所以地址都是4K的整数倍,地址的低12位都是0,所以只需要记录物理地址的第高20位即可。这样省下来的12位可以用来添加其他属性。
位 | 名称 | 作用 | =1 | = 0 | 备注 |
---|---|---|---|---|---|
P | Present | 是否在物理内存中 | 存在于物理内存中 | 不在物理内存中 | |
RW | Read/Write | 读写位 | 可读写 | 只读 | |
US | User/Supervisor | 普通用户/超级用户位 | user级,任意级别的程序都可以访问该页(级别0、1、2、3) | Supervisor级,特权级为3的程序不允许访问该页 | |
PWT | Page-level Write-Through | 页级通写位 | 此项采用通写方式,表示该页不仅是普通内存,还是高速缓存 | 此页不采用通写 | 通写是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善页的访问效率 |
PCD | Page-level Cache Disable | 页级高速缓存禁止位 | 该页启用高速缓存 | 禁止将该页缓存 | |
A | Accessed | 访问位 | 该页已被CPU访问 | 该页未被CPU访问 | 该位可以统计一段时间内某一内存页的访问频率(OS定期将该位清零,统计一段时间内变为1的次数),当内存不足时,根据频率高低将低的页换出内存。 |
D | Dirty | 脏页位 | CPU对该页执行了写操作 | CPU对该页未执行写操作 | 此项只对页表项有效,并不会修改页目录项的D位 |
PAT | Page Attribute Table | 页属性表位 | 能够在页面一级的粒度上设置内存属性,比较复杂,置零即可 | ||
G | Global | 全局位 | 该页为全局页,即该页在高速缓存TLB中一直保存 | 该页不是全局页 | 高速缓存TLB是用来缓存从虚拟地址到物理地址的映射。清空TLB有两种方式,一是用invlpg指令针对单独虚拟地址条目清理,二是重新加载cr3寄存器,直接清空TLB。 |
AVL | Available | 表示可用 | 操作系统可用该页 | 操作系统不可用该页 |
4.OS与用户进程的关系
- OS与用户进程的逻辑依赖关系:
- 为了安全,用户进程需要运行在低优先级,当用户进程需要硬件相关资源时,需要向操作系统申请,由操作系统去做,然后把结果返回给用户进程。
- 用户进程可以有无限多,但OS只有一个,所以OS必须共享给所有用户进程。
- 页表如何共享给用户进程?
- 将OS属于用户的虚拟空间即可。
- 将4GB的虚拟空间分成两部分,一部分给OS,一部分给用户进程。
- 虚拟空间的0-3GB是用户进程,3-4GB是OS,即虚拟地址的3-4GB本质上都是指向同一物理页地址。
5.启用分页机制
-
启用分页机制:
- 准备好页目录表和页表
- 将页表地址写入控制寄存器cr3中。cr3用来存储页表的物理地址,称为页目录表基址寄存器。因为一页为4KB,所以低12位都是0,只需要将页目录表物理地址的高20位写入cr3寄存器即可。
因为控制寄存器和通用寄存器可以互相传值,所以用mov即可。
指令格式:mov cr[0-7],r32或mov r32,cr[0-7]
3. 寄存器cr0的PG位置1。PG = 1表示进入内存分页运行机制,段部件输出的线性地址->虚拟地址。 -
页目录表和页表的物理地址:页目录表放在物理内存0x10_0000处,为了让页表紧挨着页目录表,第一个页表的物理地址为0x10_1000。
---------------------------代码----------------------------------
- /home/lily/OS/boot/include/boot.inc
//加入部分
;------------- loader和kernel ----------
;页目录表的物理地址,因为低端1MB用于表示内核,而0x10_0000是出1MB后的地址
PAGE_DIR_TABLE_POS equ 0x10_0000
;页表相关属性,b表示二进制
PG_P equ 1b ;P = 1表示该页存在于内存中
PG_RW_R equ 00b ;RW表示读写位,0表示只读
PG_RW_W equ 10b ;RW表示读写位,1表示可读写
PG_US_S equ 000b ;表示PTE和PDE的US属性是S,表示超级用户,该页不能被特权级为3的进程访问
PG_US_U equ 100b ;表US属性是U,表示普通用户,该页可以被所有进程访问
;-------------- gdt描述符属性 -------------
- /home/lily/OS/boot/loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; loader在保护模式下的栈指针地址,esp
; 构建GDT及其内部描述符, 每个描述符8个字节, 拆分为高低各4字节(32位)
GDT_BASE: dd 0x00000000 ; 第0个描述符,不可用
dd 0x00000000
CODE_DESC: dd 0x0000ffff ; 低32位31~16位为段基址15~0位, 15~0位为段界限15~0位
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000ffff ; 数据段(栈段)描述符
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ; 0xB8000 到0xBFFFF为文字模式显示内存 段界限:limit=(0xbffff-0xb8000) / 4k=0x7
dd DESC_VIDEO_HIGH4 ; 0xB
GDT_SIZE: equ $ - GDT_BASE ; 当前位置减去GDT_BASE的地址 等于GDT的大小
GDT_LIMIT: equ GDT_SIZE - 1 ; SIZE - 1即为最大偏移量
times 60 dq 0 ; 预留60个 四字型 描述符空位, 用于后续扩展
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 段选择子: 低3位为TI RPL状态, 其余为描述符索引值
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
; gdt指针, 前2字节为gdt界限, 后4字节为gdt起始地址(共48位)
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
; 人工对齐:total_mem_bytes 4字节 + gdt_ptr 6字节 + ards_buf 244字节 + ards_nr 2字节 , 共256字节
ards_buf times 244 db 0 ; 记录内存大小的缓冲区
ards_nr dw 0 ; 记录 ARDS 结构体数量
loader_start:
; ------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ; 第一次调用将eb置0
mov edx, 0x534d4150 ; edx只赋值一次,循环体中不会改变
mov di, ards_buf ; di指向ARDS缓冲区
.e820_mem_get_loop: ; 循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ; 执行int 0x15后,eax值变为0x534d4150, 所以每次执行int前都要更新为子功能号
mov ecx, 20 ; ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ; 若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ; 使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ; ARDS数量加1
cmp ebx, 0 ; 若 ebx 为 0 且 cf 不为 1, 这说明 ards 全部返回,当前已是最后一个
jnz .e820_mem_get_loop ; 不为0则循环获取
; 在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量
mov cx, [ards_nr] ; 遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ; 用edx记录最大值, 在这里先清零
.find_max_mem_area: ; 无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ; base_add_low
add eax, [ebx+8] ; base_add_low + length_low = 这块ADRS容量
add ebx, 20 ; 指向下一块ARDS
cmp edx, eax ; 找出最大,edx寄存器始终是最大的内存容量
jge .next_ards ; 如果edx>=eax, 继续遍历下一块
mov edx, eax ; 如果edx<=eax, 更新edx
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok ; 获取内存容量结束
; ------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G
.e820_failed_so_try_e801:
mov ax, 0xe801
int 15
jc .e801_failed_so_try88 ; 若当前e801方法失败,就尝试0x88方法
; 1 先算出低15M的内存, ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
; 乘数在eax, 即内存数量, 积高16位在edx, 低16位在eax
mov cx, 0x400 ; 0x400 = 1024
mul cx
shl edx, 16 ; 左移16位, 将低16位放到edx高16位
and eax, 0x0000FFFF ; 0x0000FFFF = 1111 1111 1111 1111, 高16位置0
or edx, eax ; 获得完整的32位积
add edx, 0x100000 ; edx比实际少1M, 故要加1MB
mov esi, edx ; 先把低15MB的内存容量存入esi寄存器备份
; 2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax, eax
mov ax, bx
mov ecx, 0x10000 ; 0x10000十进制为64KB
mul ecx ; 32位乘法,默认的被乘数是eax, 积为64位, 高32位存入edx, 低32位存入eax
add esi, eax ; 由于此方法只能测出4G以内的内存, 故32位eax足够了, edx肯定为0, 只加eax便可
mov edx, esi ; edx为总内存大小
jmp .mem_get_ok
; ----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
; int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000FFFF
; 16位乘法,被乘数是ax,积为32位, 积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ; 0x400等于1024, 将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16
or edx, eax ; 把积的低16位组合到edx,为32位的积
add edx, 0x100000 ; 0x88子功能只会返回1MB以上的内存, 故实际内存大小要加上1MB
.mem_get_ok:
mov [total_mem_bytes], edx ; 将内存换为byte单位后存入total_mem_bytes处
; --------------------------------- 设置进入保护模式 -----------------------------
; 1 打开A20 gate
; 2 加载gdt
; 3 将cr0 的 pe位(第0位)置1
; ----------------- 打开A20 ----------------
in al, 0x92 ; 端口号0x92 中的第1位变成 1 即可
or al, 0000_0010B
out 0x92, al
; ----------------- 加载GDT ----------------
lgdt [gdt_ptr] ;lgdt 48位内存数据表示访问gdtr
; ----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
; -------------------------------- 已经打开保护模式 ---------------------------------------
jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线
.error_hlt: ; 出错则挂起
hlt ; 处理器暂停, 直到出现中断或复位信号才继续
[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'
;创建页目录表及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr中,一会儿用新地址加载
sgdt [gdt_ptr] ;存储到原来gdt所有的位置,sgdt可以获取gdt表的位置,可以在3环和0环运行,会将gdtr寄存器能容返回
;因为内核运行在3GB之上,打印功能肯定要在内核中运行,不能让用户程序控制显存
;将gdt描述符中视频段描述符中的段基址+0xc000_0000
mov ebx,[gdt_ptr+2] ;gdt前2字节是偏移量,后4字节是基址,这里是取得gdt基址
or dword [ebx+0x18+4],0xc000_0000
;视频段是第3个段描述符,每个描述符是8字节,故0x18
;段描述符的最高4字节用来记录段基址,段基址的第31-24位
;修改完显存描述符后,来修改gdt基址,我们把gdt也移到内核空间中
;将gdt的基址加上0xc000_0000使其成为内核所在的最高位
add dword [gdt_ptr+2],0xc000_0000
add esp,0xc000_0000 ;将栈指针同样映射到内核地址
;把页目录地址赋给cr3
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax
;打开cr0的pg位
mov eax,cr0
or eax,0x8000_0000
mov cr0,eax
;在开启分页后,用gdt新地址重新加载
lgdt [gdt_ptr] ;重新加载
mov byte [gs:160],'V'
jmp $
;------------------创建页目录表及页表-------------------
setup_page:
;先把页目录表占用的空间逐字清零
mov ecx,4096 ;4KB,ecx = 0用于loop循环的终止条件
mov esi,0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;PAGE_DIR_TABLE_POS用于定义页目录表的物理地址
inc esi ;esi++,PAGE_DIR_TABLE_POS为基址,esi为变址
loop .clear_page_dir
;开始创建页目录项(PDE)
.create_pde: ;创建Page Directory Entry
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;此时eax为第一个页表的位置和属性,0x10_1000
mov ebx,eax ;此处为ebx赋值,是为.create_pte做准备,ebx是基址,在.create_pte中使用
;下面将页目录项0和0xc00都存为第一个页表的地址,指向同一个页表,每个页表表示4MB内存
;这样0xc03f_ffff以下的地址和0x003f_ffff以下的地址都指向相同的页表
;这是为将地址映射为内核地址做准备
or eax,PG_US_U | PG_RW_W | PG_P ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问,逻辑或后结果为0x7
mov [PAGE_DIR_TABLE_POS + 0x0],eax ;第一个目录项,在页目录表中第一个页目录项写入第一个页表的位置(0x10_1000)及属性(7),eax = 0x10_1007
mov [PAGE_DIR_TABLE_POS + 0xc00],eax
;一个页表项占4B,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,也就是页表的0xc000_0000-0xffff_ffff共计1G属于内核
;0x0-0xbfff_ffff共计3G属于用户进程
sub eax,0x1000 ;eax = 0x10_0000
mov [PAGE_DIR_TABLE_POS + 4092],eax ;使最后一个目录项指向页目录表自己的地址,4096-4=4092,因为一个目录项4B,为了将来能动态操作页表
;下面创建页表项PTE
;创建第一个页表0x10_1000,它用来分配物理范围0-0x3f_ffff之间的物理页,也就是虚拟地址0-0x3f_ffff和0xc000_0000-0xc03f_ffff对应的物理页。
mov ecx,256 ;因为目前只用到了1MB内存,所以只分配这1MB。1M低端内存/每页大小4k = 256页,即256个页表项,一共要创建256个页表
mov esi,0
mov edx,PG_US_U | PG_RW_W | PG_P ;属性为7,US=1,RW=1,P=1
.create_pte: ;创建Page Table Entry
mov [ebx + esi*4],edx ;向页表项中写入页表地址;此时ebx已经通过上面eax赋值为0x10_1000,也就是第一个页表的地址
add edx,4096 ;edx + 4KB指向下一个页表的起始地址(第二个页表)
inc esi
loop .create_pte
;创建内核其他页表的PDE,即内核空间中除第0个页表外的其余所有页表对应的页目录项
mov eax,PAGE_DIR_TABLE_POS
add eax,0x2000 ;此时eax为第二个页表的位置
or eax,PG_US_U |PG_RW_W |PG_P ;页目录项的属性US、RW和P位都是1
mov ebx,PAGE_DIR_TABLE_POS
mov ecx,254 ;范围为第769-1022的所有目录项数量,第255个已经指向了页目录表本身
mov esi,769
.create_kernel_pde:
mov [ebx+esi*4],eax ;从第二个页目录项开始写
inc esi
add eax,0x1000
loop .create_kernel_pde
ret
- 编译代码
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S
dd if=/home/lily/OS/boot/mbr.bin of=/home/lily/bochs/hd60M.img bs=512 count=1 conv=notrunc
dd if=/home/lily/OS/boot/loader.bin of=/home/lily/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
- 运行代码 启动bochs
bin/bochs -f bochsrc.disk
5.用虚拟机访问页表
Ctrl + C
info tab //因为bochs版本不同所以和书上不一样,但也是对的
6.用虚拟地址访问页表
- 页表增减:在申请或释放内存时,页表中对应的页表项或页目录项要及时更改,这正是二级页表的灵活之处,可以根据需求动态增减。
- 用虚拟地址获取页表的方法:
- 获取页目录表物理地址:页目录表在页表的最后一个页表项中,即0xf_ffff,低12位为0x000。
- 访问页目录表中的页目录项:页目录表在0xfffff000中,所以用0xfffffxxx来访问页目录项,xxx表示页目录项的索引乘4的积。
- 访问页表中的页表项:页目录表(物理地址)在最后一个页目录项中(虚拟地址),所以高20位为0x3ff,中间10位为页表的索引,不需要乘4,最后12位是页表内的偏移地址,用来定位页表项,需要乘4。
即:0x3ff << 中间10位 << 低12位
7.快表TLB(Translation Lookaside Buffer 翻译暂存缓冲区)
- 快表:CPU的高速缓存,它专门用来存放虚拟地址页框和物理地址页框的映射关系,用来匹配高速的处理器速率和低速的内存访问速度。
- 结构:TLB中的条目是虚拟地址的高20位->物理地址的高20位的映射,实际上是从虚拟页框->物理页框的映射。此外,TLB还有一些属性位。
- TLB的更新:
- 一般的缓存可以定期更新,但TLB必须实时更新,因为TLB里面存储的是程序运行所依赖的指令和数据的内存地址。但如果实时读取内存中的页表去更新TLB的话,这又回到了从内存查询映射关系的老路,因此,TLB由开发人员手动控制。
- 虽然TLB对开发人员不可见,但还是有两种方法可以间接更新TLB的:
- 重新加载cr3:将cr3寄存器的数据读出再写入cr3,会使整个TLB失效。
- invlpg(invalidate page):针对TLB中某个条目的更新。
指令格式:invlpg m。m为虚拟内存地址,并不是立即数。
例如:更新虚拟地址0x1234对应的条目,指令为invlpg [0x1234]而不是invlpg 0x1234
三、加载内核
1. 用c语言写内核
- 可行性:对于机器而言,它接受的是机器指令。无论哪种语言,只要能编译成机器语言就可以被机器执行。而C语言是可以被编译成机器指令的(我们平时编译成的二进制文件,它里面都是二进制的机器指令),CPU处理起来完全没有问题。
- c语言程序的编译运行:
- 先将源程序编译成目标文件
- 由c代码->汇编代码->二进制的目标文件
- gcc编译器:gcc -c -o kernel/main.o kernel/main.c
-
-c:编译、汇编到目标文件,不进行链接,也就是直接生成目标文件
-o:将输出的文件以指定文件名来存储,有同名文件存在时直接覆盖 - main.o:是个目标文件(可重定位文件)。重定位是指文件中的符号还没有安排地址,这些符号的地址需要将来与其他目标文件链接成一个可执行文件时再重定位(编排地址)。这里的符号就是指所调用的函数或使用的变量,由于这些符号一般存在于其他文件中,所以此刻不能确定地址,要等所有目标文件到齐了,才能链接到一起再重新定位。
- 哪怕可执行文件是由一个文件组成的,其目标文件中的符号也是未编址的,重定位一律在链接阶段完成。
- 可以用file命令来检查main.o的状态:file kernel/main.o:
- 再将目标文件链接成二进制可执行文件
- 在linux下用于链接的程序是ld,链接有个好处——可以指定最终生成的可执行文件的起始虚拟地址,用-Ttext参数来指定。
- ld kernel/main.o -Ttext 0xc000_1500 -e main -o kernel/kernel.bin
-
-Ttext:指定起始虚拟地址为0xc000_1500
-o:指定输出的文件名
-e:指定文件的入口地址,编译器默认把名为_start的函数作为程序的入口地址。平时我们写的代码都是main函数,但链接器还是用到了_start,它不是我们提供的代码,是运行库提供的。这说明main函数不是第一个执行的代码,是被其他代码调用的,main函数在运行库代码初始化完环境后才被调用。 - /home/lily/OS/boot/kernel/main.c
int main(void){ while (1); return 0; }
- 编译命令:gcc -c -o kernel/main.o kernel/main.c
- 查看main.o的状态:file kernel/main.o
- 查看符号的地址:nm kernel/main.o
- 链接:ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
- 先将源程序编译成目标文件
- 汇编比c快的原因:汇编语言对应的机器指令是一对一的,简单;c语言对应的机器指令是一对多的,冗余。
2. 二进制文件的运行方法
- 如何加载用户程序?
- jmp和call指令:用一个程序去调用另一个程序,最简单的方法就是用jmp和call指令。BIOS就是这样调用mbr的,mbr的地址是0x7c00;mbr也是这样调用loader的,loader的地址是0x900。这两个地址是固定的,因此很不灵活,调用方需要提前和被调用方约定地址。
- 头文件
- 形式:文件头header+文件体body
- 元数据:在程序中,文件头用来描述程序的布局等,它属于信息的信息,也就是元数据。
- 本质:因为每个程序是单独存在的,所以程序的入口地址需要与程序绑定,所以需要在头文件中写入程序的入口地址。主调函数在该程序文件的头文件中将该程序的入口信息读出,将其加载到相应的入口地址,然后跳转过去。
- 在程序中,程序头用来描述程序的布局等信息,它属于信息的信息,也就是元数据。
- 执行方式:由于程序文件中包含了程序头,程序的入口地址不需要写死,调用方中的调用代码可以变得通用。但不好的地方是这些元信息不是代码,不能放在CPU中执行。所以,将这种具有头文件的程序文件从外存读入内存后,从头文件中读出入口地址,需要直接跳进入口地址执行,跳过头文件才行。
//自定义文件头 header.S 测试代码,无实例
header:
program_length dd program_end-program_start
start_addr dd program_start
;---------以上是文件头,以下是文件体------------
body:
program_start:
mov ax,0x1234
jmp $
program_end:
- header.S的被调用方式:
- 将header.bin前8字节的内容读到内存,前4字节是程序体长度,后4字节是程序的入口地址。
- 将header.bin开头偏移8字节的地方作为起始,将header.bin文件尾作为终止。
- 将起始地址和终止地址之间的程序体复制到入口地址。
- 转到入口地址处执行。
3. ELF格式的二进制文件
- Windows下的可执行文件格式的PE(exe是扩展名,属于文件名的一部分,只是名字的后缀);Linux下的可执行文件格式是ELF。
- ELF:Executable and Linkable Format,可执行链接格式。
- 把符合ELF格式协议的文件统称为“目标文件”或“ELF文件”,本节中所说的目标文件就是指符合ELF规范的文件。
- 段(segment)和节(section):
- 关系:多个节section经过链接后被合并成一个段segment。
- 程序头和节头:用来描述段和节的信息的,程序头是program header,节头是section header。
- 程序头表和节头表:因为程序中的段和节的大小和数量是不固定,因此程序头表(program header table)和节头表(section header table)是专门用来描述它们的。表类似于数组,里面存放着多个程序头和节头。
- 在表中,每个成员都统称为条目entry,一个条目代表一个段/节的头描述信息。
- 对于程序头表,它本质上就是描述段(segment)的,所以也成为段头表。段等同于程序,可见“段”才是程序本身的组成部分。
- elf header
- 因为段和节的数量不固定,所以程序头表和节头表的大小也不固定。因此,有个专门的数据结构来存放程序头表和节头表,即elf header。
- elf header是用来描述各种“头”的头,核心思想是头中嵌头,是种层次化结构的格式。
- ELF结构中的数据结构:
- ELF header的结构:
- e_ident[16] 是16字节大小的数组,用来表示ELF字符等信息。开头的4B是魔数0x7f以及字符串ELF的ASCII码:0x45 0x4c 0x46
- e_type:用来指定ELF目标文件的类型。
- e_machine:用来描述ELF目标文件的体系结构类型,也就是该文件要在哪种硬件平台上运行。
- e_version:表示版本信息。
- e_entry:指明OS运行该程序时,将控制权交给虚拟地址。
- e_phoff:指明程序头表(program header table)在文件内的字节偏移量。若没有程序头表,则为0。
- e_shoff:指明节头表(section header table)在文件内的字节偏移量。若没有节头表,则为0。
- e_flags:指明与处理器相关的标志。
- e_ehsize:指明ELF header的字节大小。
- e_phentsize:指明程序头表中每个条目的字节大小,即每个用来描述段信息的数据结构的字节大小,即struct Elf32_Phdr。
- e_phnum:指明程序头表中条目的数量,即段数。
- e_shentsize :指明节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小。
- e_shnum:指明节头表中条目的数量,即节数。
- e_shstrndx:指明string name table在节头表中的索引index。
- e_ident[16] 是16字节大小的数组,用来表示ELF字符等信息。开头的4B是魔数0x7f以及字符串ELF的ASCII码:0x45 0x4c 0x46
- struct Elf32_Phdr:程序头表中条目的数据结构
-
p_type:指明程序中该段的类型。
-
p_offset:指明本段在文件内的起始偏移地址。
-
p_vaddr:指明本段在内存中的起始虚拟地址。
-
p_paddr:仅用于与物理地址相关的系统中。
-
p_filesz:指明本段在文件中的大小。
-
p_memsz:指明本段在内存中的大小。
-
p_flags:指明与本段相关的标志。
-
p_align:指明本段在文件和内存中的对齐方式。
-
- 链接后,程序运行的代码、数据等资源都在段中。
4. 将内核载入内存
- 向磁盘写入的命令:
dd if=kernel.bin of=/your_path/hd60M.img bs=512 count=200 seek=9 conv=notrunc
- 链接为脚本再写入磁盘的命令:
gcc -c -o main.o main.c && ldmain.o -Ttext 0xc0001500 -e main -o kernel.bin && dd if=kernel.bin of=/your_path/hd60M.img bs=512 count=200 seek=9 conv=notrunc
- loader.S的修改:
- 加载内核:需要将内核文件加载到内存缓冲区
- 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳转过去执行,从此loader的工作结束。
- 加载内核
- 把内核文件从硬盘拷贝到内存中,并不运行内核代码。
- 在分页前后都可以,书中在分页前加载。
- 内核加载到内存中,要有个加载地址,即缓冲区。缓冲区buffer意味着暂时存放数据的地方。
- 因为内核很小,在低端1MB中安放即可;下图中打勾的是可用区域,MBR刚刚结束使命就要被覆盖掉了。
- 我们在0x7e00~0x9fbff这片区域中找一个高地址来存放kernel.bin,本书选择了0x70000。因为0x9fbff - 0x70000 = 0x2fbff = 190KB,而内核不会超过100KB,所以肯定够用且为整数。
- 初始化内核
- 内核被加载到内存后,loader要通过分析其ELF结构将其展开到新的位置。因此,银河在内存中有两份拷贝:一份是ELF格式的源文件kernel.bin,另一份是loader解析ELF格式的kernel.bin后再在内存中生成的内核映像(也就是将程序中的各种段segment复制到内存后的程序体),这个映像才是真正运行的内核。
- 因为内核肯定是越来越大,为了预留出生长空间,我们将内核文件kernel.bin加载到地址较高的空间;而内核映像放置在地址较低的空间。内核文件经过loader解析后就没用了,所以内核映像向高地址扩展时,可以覆盖掉原来的kernel.bin。
- 在分页模式下,程序是靠虚拟地址来运行的,交给CPU的指令/数据的地址一律被认为是虚拟地址。即向安排内核在哪片虚拟内存中,就将内核地址编译成对应的虚拟地址。
- 我们考虑要选择哪个虚拟地址作为内核映像的入口地址。0x900处是loader.bin加载的地址,在loader.bin的开始部分是GDT,它是不能被覆盖的。预计loader.bin不会超过2000B,所以我们可选的物理地址为0x900+2000(0x7d0) = 0x9d0,为了凑整数,选择了0x1500。
- 在我们的页表中,低端1MB的虚拟内存与物理地址是一一对应的,所以物理地址为0x1500,对应的虚拟地址为0xc000_1500。
5.-------------------------代码-----------------------------------
- “/home/lily/OS/boot/include/boot.inc”
LOADER_START_SECTOR equ 0x2
;-----------新加入的内容--------------------
KERNEL_START_SECTOR equ 0x9
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_ENTRY_POINT equ 0xc000_1500
PT_NULL equ 0
;------------------------------------------
- “/home/lily/OS/boot/loader.S”
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; loader在保护模式下的栈指针地址,esp
; 构建GDT及其内部描述符, 每个描述符8个字节, 拆分为高低各4字节(32位)
GDT_BASE: dd 0x00000000 ; 第0个描述符,不可用
dd 0x00000000
CODE_DESC: dd 0x0000ffff ; 低32位31~16位为段基址15~0位, 15~0位为段界限15~0位
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000ffff ; 数据段(栈段)描述符
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ; 0xB8000 到0xBFFFF为文字模式显示内存 段界限:limit=(0xbffff-0xb8000) / 4k=0x7
dd DESC_VIDEO_HIGH4 ; 0xB
GDT_SIZE: equ $ - GDT_BASE ; 当前位置减去GDT_BASE的地址 等于GDT的大小
GDT_LIMIT: equ GDT_SIZE - 1 ; SIZE - 1即为最大偏移量
times 60 dq 0 ; 预留60个 四字型 描述符空位, 用于后续扩展
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 段选择子: 低3位为TI RPL状态, 其余为描述符索引值
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
; gdt指针, 前2字节为gdt界限, 后4字节为gdt起始地址(共48位)
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
; 人工对齐:total_mem_bytes 4字节 + gdt_ptr 6字节 + ards_buf 244字节 + ards_nr 2字节 , 共256字节
ards_buf times 244 db 0 ; 记录内存大小的缓冲区
ards_nr dw 0 ; 记录 ARDS 结构体数量
loader_start:
; ------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ; 第一次调用将eb置0
mov edx, 0x534d4150 ; edx只赋值一次,循环体中不会改变
mov di, ards_buf ; di指向ARDS缓冲区
.e820_mem_get_loop: ; 循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ; 执行int 0x15后,eax值变为0x534d4150, 所以每次执行int前都要更新为子功能号
mov ecx, 20 ; ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ; 若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ; 使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ; ARDS数量加1
cmp ebx, 0 ; 若 ebx 为 0 且 cf 不为 1, 这说明 ards 全部返回,当前已是最后一个
jnz .e820_mem_get_loop ; 不为0则循环获取
; 在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量
mov cx, [ards_nr] ; 遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ; 用edx记录最大值, 在这里先清零
.find_max_mem_area: ; 无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ; base_add_low
add eax, [ebx+8] ; base_add_low + length_low = 这块ADRS容量
add ebx, 20 ; 指向下一块ARDS
cmp edx, eax ; 找出最大,edx寄存器始终是最大的内存容量
jge .next_ards ; 如果edx>=eax, 继续遍历下一块
mov edx, eax ; 如果edx<=eax, 更新edx
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok ; 获取内存容量结束
; ------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G
.e820_failed_so_try_e801:
mov ax, 0xe801
int 15
jc .e801_failed_so_try88 ; 若当前e801方法失败,就尝试0x88方法
; 1 先算出低15M的内存, ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
; 乘数在eax, 即内存数量, 积高16位在edx, 低16位在eax
mov cx, 0x400 ; 0x400 = 1024
mul cx
shl edx, 16 ; 左移16位, 将低16位放到edx高16位
and eax, 0x0000FFFF ; 0x0000FFFF = 1111 1111 1111 1111, 高16位置0
or edx, eax ; 获得完整的32位积
add edx, 0x100000 ; edx比实际少1M, 故要加1MB
mov esi, edx ; 先把低15MB的内存容量存入esi寄存器备份
; 2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax, eax
mov ax, bx
mov ecx, 0x10000 ; 0x10000十进制为64KB
mul ecx ; 32位乘法,默认的被乘数是eax, 积为64位, 高32位存入edx, 低32位存入eax
add esi, eax ; 由于此方法只能测出4G以内的内存, 故32位eax足够了, edx肯定为0, 只加eax便可
mov edx, esi ; edx为总内存大小
jmp .mem_get_ok
; ----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
; int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000FFFF
; 16位乘法,被乘数是ax,积为32位, 积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ; 0x400等于1024, 将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16
or edx, eax ; 把积的低16位组合到edx,为32位的积
add edx, 0x100000 ; 0x88子功能只会返回1MB以上的内存, 故实际内存大小要加上1MB
.mem_get_ok:
mov [total_mem_bytes], edx ; 将内存换为byte单位后存入total_mem_bytes处
; --------------------------------- 设置进入保护模式 -----------------------------
; 1 打开A20 gate
; 2 加载gdt
; 3 将cr0 的 pe位(第0位)置1
; ----------------- 打开A20 ----------------
in al, 0x92 ; 端口号0x92 中的第1位变成 1 即可
or al, 0000_0010B
out 0x92, al
; ----------------- 加载GDT ----------------
lgdt [gdt_ptr] ;lgdt 48位内存数据表示访问gdtr
; ----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
; -------------------------------- 已经打开保护模式 ---------------------------------------
jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线
.error_hlt: ; 出错则挂起
hlt ; 处理器暂停, 直到出现中断或复位信号才继续
[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
;---------------------加载kernel-------------------------
mov eax,KERNEL_START_SECTOR ;kernel.bin所在扇区号
mov ebx,KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址
mov ecx,200 ;读入的扇区数
call rd_disk_m_32
;--------------------------------------------------------
;创建页目录表及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr中,一会儿用新地址加载
sgdt [gdt_ptr] ;存储到原来gdt所有的位置,sgdt可以获取gdt表的位置,可以在3环和0环运行,会将gdtr寄存器能容返回
;因为内核运行在3GB之上,打印功能肯定要在内核中运行,不能让用户程序控制显存
;将gdt描述符中视频段描述符中的段基址+0xc000_0000
mov ebx,[gdt_ptr+2] ;gdt前2字节是偏移量,后4字节是基址,这里是取得gdt基址
or dword [ebx+0x18+4],0xc000_0000
;视频段是第3个段描述符,每个描述符是8字节,故0x18
;段描述符的最高4字节用来记录段基址,段基址的第31-24位
;修改完显存描述符后,来修改gdt基址,我们把gdt也移到内核空间中
;将gdt的基址加上0xc000_0000使其成为内核所在的最高位
add dword [gdt_ptr+2],0xc000_0000
add esp,0xc000_0000 ;将栈指针同样映射到内核地址
;把页目录地址赋给cr3
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax
;打开cr0的pg位
mov eax,cr0
or eax,0x8000_0000
mov cr0,eax
;在开启分页后,用gdt新地址重新加载
lgdt [gdt_ptr] ;重新加载
;------------此时不刷新流水线也没关系,为了以防万一还是加上
jmp SELECTOR_CODE:enter_kernel
enter_kernel:
call kernel_init
mov esp,0xc009_f000
jmp KERNEL_ENTRY_POINT
;------------------将kernel.bin中的segment拷贝到编译的地址---------------
kernel_init: ;全部清零
xor eax,eax
xor ebx,ebx ;ebx记录程序头表地址
xor ecx,ecx ;cx记录程序头表中的program header数量
xor edx,edx ;dx记录program header尺寸,即e_phentsize
mov dx,[KERNEL_BIN_BASE_ADDR + 42] ;dx = e_phentsize:偏移文件42字节处的属性是e_phentsize,表示program header的大小
mov ebx,[KERNEL_BIN_BASE_ADDR + 28] ;ebx = e_phoff:偏移文件开始部分28字节的地方是e_phoff,表示第一个program header在文件中的偏移量。这里是将e_phoff给ebx而不是KERNEL_BIN_BASE_ADDR + 28的地址
add ebx,KERNEL_BIN_BASE_ADDR ;ebx = KERNEL_BIN_BASE_ADDR + e_phoff = 程序头表的物理地址
mov cx,[KERNEL_BIN_BASE_ADDR + 44] ;cx = e_phnum:偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment: ;分析每个段,如果不是空程序类型,则将其拷贝到编译的地址中
cmp byte [ebx + 0],PT_NULL ;程序先判断下段类型是不是PT_NULL,表示空段类型
je .PTNULL ;如果p_type = PTNULL(空程序类型),说明此program header未使用,则跳转到下一个段头
;为函数mem_cpy(dst,src,size)压入参数,参数从右往左依次压入
push dword [ebx + 16] ;push f_filesz:program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数size
mov eax,[ebx + 4] ;eax = p_offset:距程序头偏移量为4字节的位置是p_offset
add eax,KERNEL_BIN_BASE_ADDR ;eax = KERNEL_BIN_BASE_ADDR + p_offset = 该段的物理地址:加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ;push 该段的物理地址:压入memcpy的第二个参数:源地址
push dword [ebx + 8] ;push p_vaddr:压入memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr
call mem_cpy ;调用mem_cpy完成段复制
add esp,12 ;清理栈中压入的三个参数,每个4B
.PTNULL:
add ebx,edx ;edx为program header大小,即e_phentsize;每遍历一个段头,就跳转到下一个段头处
loop .each_segment ;在此ebx指向下一个program header
ret
;------------逐字节拷贝mem_cpy(dst,src,size)-------------
;输入:栈中三个参数(dst,src,size)
;输出:无
;-------------------------------------------------------
mem_cpy:
cld ;clean direction,将eflags寄存器中的方向标志位DF置0,这样rep在循环执行后面的字符串指令时,esi和edi根据使用的字符串搬运指令,自动加上所搬运数据的字节大小。
push ebp
mov ebp,esp ;esp是栈顶指针
push ecx ;rep指令用到了ecx,但ecx对于外层段的循环还有用,所以先入栈备份
mov edi,[ebp + 8] ;dst
mov esi,[ebp + 12] ;src
mov ecx,[ebp + 16] ;size
rep movsb ;逐字节拷贝:movs表示mov string,b表示byte,w表示word,d表示dword。将DS:EI/SI指向的地址处的字节搬运到ES:DI/EI指向的地址去。
;16位环境下源地址指针用SI寄存器,目的地址指针用DI寄存器;32位环境下源地址用SI寄存器,目的地址用EDI寄存器。
;恢复环境
pop ecx
pop ebp
ret ;在调用ret时,栈顶处的数据是正确的返回地址。一般情况下,我们在函数体中保持push和pop配对使用。
;------------------创建页目录表及页表-------------------
setup_page:
;先把页目录表占用的空间逐字清零
mov ecx,4096 ;4KB,ecx = 0用于loop循环的终止条件
mov esi,0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;PAGE_DIR_TABLE_POS用于定义页目录表的物理地址
inc esi ;esi++,PAGE_DIR_TABLE_POS为基址,esi为变址
loop .clear_page_dir
;开始创建页目录项(PDE)
.create_pde: ;创建Page Directory Entry
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;此时eax为第一个页表的位置和属性,0x10_1000
mov ebx,eax ;此处为ebx赋值,是为.create_pte做准备,ebx是基址,在.create_pte中使用
;下面将页目录项0和0xc00都存为第一个页表的地址,指向同一个页表,每个页表表示4MB内存
;这样0xc03f_ffff以下的地址和0x003f_ffff以下的地址都指向相同的页表
;这是为将地址映射为内核地址做准备
or eax,PG_US_U | PG_RW_W | PG_P ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问,逻辑或后结果为0x7
mov [PAGE_DIR_TABLE_POS + 0x0],eax ;第一个目录项,在页目录表中第一个页目录项写入第一个页表的位置(0x10_1000)及属性(7),eax = 0x10_1007
mov [PAGE_DIR_TABLE_POS + 0xc00],eax
;一个页表项占4B,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,也就是页表的0xc000_0000-0xffff_ffff共计1G属于内核
;0x0-0xbfff_ffff共计3G属于用户进程
sub eax,0x1000 ;eax = 0x10_0000
mov [PAGE_DIR_TABLE_POS + 4092],eax ;使最后一个目录项指向页目录表自己的地址,4096-4=4092,因为一个目录项4B,为了将来能动态操作页表
;下面创建页表项PTE
;创建第一个页表0x10_1000,它用来分配物理范围0-0x3f_ffff之间的物理页,也就是虚拟地址0-0x3f_ffff和0xc000_0000-0xc03f_ffff对应的物理页。
mov ecx,256 ;因为目前只用到了1MB内存,所以只分配这1MB。1M低端内存/每页大小4k = 256页,即256个页表项,一共要创建256个页表
mov esi,0
mov edx,PG_US_U | PG_RW_W | PG_P ;属性为7,US=1,RW=1,P=1
.create_pte: ;创建Page Table Entry
mov [ebx + esi*4],edx ;向页表项中写入页表地址;此时ebx已经通过上面eax赋值为0x10_1000,也就是第一个页表的地址
add edx,4096 ;edx + 4KB指向下一个页表的起始地址(第二个页表)
inc esi
loop .create_pte
;创建内核其他页表的PDE,即内核空间中除第0个页表外的其余所有页表对应的页目录项
mov eax,PAGE_DIR_TABLE_POS
add eax,0x2000 ;此时eax为第二个页表的位置
or eax,PG_US_U |PG_RW_W |PG_P ;页目录项的属性US、RW和P位都是1
mov ebx,PAGE_DIR_TABLE_POS
mov ecx,254 ;范围为第769-1022的所有目录项数量,第255个已经指向了页目录表本身
mov esi,769
.create_kernel_pde:
mov [ebx+esi*4],eax ;从第二个页目录项开始写
inc esi
add eax,0x1000
loop .create_kernel_pde
ret
;读取文件到内存
;eax:扇区号 ebx:待读入的地址 ecx:读入的扇区数
rd_disk_m_32:
mov esi,eax ;备份eax
mov di,cx ;备份cx
;读写硬盘
;1.设置待读取的扇区数
mov dx,0x1f2 ;设置端口号,dx用来存储端口号,要写入待读入的扇区数
mov al,cl
out dx,al ;待读入的扇区数
mov eax,esi ;恢复eax
;2.将LBA地址存入0x1f3~0x1f6
;LBA 7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al
;LBA 15~8位写入端口0x1f4
mov cl,0x08
shr eax,cl ;逻辑右移8位,将eax的低8位移掉。
mov dx,0x1f4
out dx,al
;LBA 24~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al
shr eax,cl
and al,0x0f ;设置lba的24~27位
or al,0xe0 ;设置7~4位是1110表示LBA模式
mov dx,0x1f6
out dx,al
;3.向0x1f7端口写入读命令0x20
mov dx,0x1f7
mov al,0x20
out dx,al
;4.检测硬盘状态
.not_ready:
;同写入命令端口,读取时标示硬盘状态,写入时是命令
nop
in al,dx
and al,0x88 ;第3位=1表示已经准备好,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5.0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512B,一次读取2B,需要读取256次
mul dx ;结果放在ax中
mov cx,ax ;要读取的次数
mov dx,0x1f0
.go_on_read:
in ax,dx
mov [ebx],ax ;bx是要读取到的内存地址
add ebx,0x02
loop .go_on_read;循环cx次
ret
- 编译:
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S
dd if=/home/lily/OS/boot/mbr.bin of=/home/lily/bochs/hd60M.img bs=512 count=1 conv=notrunc
dd if=/home/lily/OS/boot/loader.bin of=/home/lily/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
- “/home/lily/OS/boot/kernel/main.c”
int main(void){
while (1);
return 0;
}
- 编译链接(在64位系统中编译32位程序)
gcc -m32 -c -o kernel/main.o kernel/main.c
ld -m elf_i386 kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
dd if=/home/lily/OS/boot/kernel/kernel.bin of=/home/lily/bochs60M.img bs=512 count=200 seek=9 conv=notrunc
- 运行代码,启动bochs
bin/bochs -f bochsrc.disk
//在bochs目录下运行
- Ctrl + C
- 下一条指令是死循环,与while(1)一致,说明代码运行起来了。
四、特权级深入浅出
1. 特权级
- CPU负责维护计算机内的安全,它将程序拥有的权力分为4个等级:0、1、2、3;数字越小,权力越大。
- 0特权级是OS内核所在的特权级,必须让OS处于至高无上的地位。计算机在启动之初就是以0特权级运行的,MBR是我们写的第一个程序,自从它从BIOS那里接过第一棒的时候,它就已经处于0特权级了。
- OS位于最内环的0特权级,它直接控制硬件,掌控各种核心数据。
- 系统程序分别位于1和2特权级,运行在这两层的程序一般是虚拟机、驱动程序等系统服务。
- 3特权级的是用户程序,用户程序被设计为“有需求时找OS”。
2. TSS
- 定义:Task State Segment,任务状态段,它是处理器在硬件上原生支持多任务的一种实现方式。
- TSS是每个任务都有的结构,它用于一个任务的标识,相当于任务的身份证,程序拥有此结构才能运行。
- 在没有OS的情况下,进程就是任务,任务就是一段在CPU上运行的程序;在有了OS之后,程序可以分为用户程序和操作系统内核程序。因此,一个任务按照特权级划分的话,是分为0特权级和3特权级两部分。完整的任务要经历这两种特权级的变换。
- 任务是由CPU执行的,任务特权级的变换,实际上是CPU的当前特权级在变换。
- 处理器在不同特权级下,应用不同特权级的栈。一个任务在每个特权级下只能有一个栈,当CPU进入不同的特权级时,它自动在TSS中找同特权级的栈。而TSS中只有三个栈:ss0和esp0、ss1和esp1、ss2和esp2。分别代表0、1、2特权级的段选择子和偏移量。
- 特权级转移分类:
- 低特权级->高特权级:由中断门、调用门实现;由于不知道目标特权级对应的栈地址在哪,所以提前把目标栈的地址记录在TSS中,当处理器向高特权级转移时会自动从中取出加载到SS和ESP中更新栈。所以TSS中记录的栈是转移后的高特权级的目标栈,因此TSS中不需要记录3特权级的栈,因为是最低级的栈,没有特权级会向它转移。
- 高特权级->低特权级:由返回指令实现;只有先向更高特权级转移后,才能回到低特权级。所以当CPU从低向高特权级转移时,会将当前低特权级压入转移后高特权级所在的栈中。
- CPU如何找到TSS:TSS是硬件支持的系统数据结构,它和GDT一样,由软件填写其内容,由硬件使用。GDT也要加载到寄存器GDTR中才能被处理器找到,TSS是由寄存器TR(Task Register)加载的,每次CPU处理不同任务时,将TR寄存器加载不同任务的TSS就可以了。
3. CPL和DPL入门
- 计算机特权级的标签体现在DPL、CPL和RPL。
- RPL(Request 请求特权级)
- 在计算机中,具有“能动性”的只有计算机指令,只有指令才具备访问、请求其他资源的能力。指令“请求”“访问”其他资源的能力等级称为“请求特权级”,指令放在代码段中。
- 代码段寄存器CS中选择子的RPL位表示代码请求别人资源能力的等级,它又称为处理器的当前特权级,即处理器的当前特权级为CS.RPL。
- CPL(Current 当前特权级)
- 在CPU中运行的是指令,其运行过程中的指令总会属于某个代码段,该代码段的特权级,也就是代码段描述符中的DPL,便是当前CPU所处的特权级,这个特权级被称为当前特权级CPL,表示CPU正在执行的代码的特权级。
- 当前特权级CPL保存在CS选择子中的RPL部分。
- RPL变为CPL,其实只是代码段寄存器CS中的RPL是CPL,其他段寄存器中选择子的RPL与CPL无关。因为CPL是针对具有“能动性”的访问者而言的,代码是访问的请求者,所以CPL只存放在代码段寄存器CS中低2位的RPL中。
- DPL(Descriptor 描述符特权级)
- DPL在段描述符中占2位,可以表示4个组合:00b、01b、10b和11b。
- DPL是段描述符所代码的内存区域的“门槛”权限,访问者能否迈过此门槛访问到本描述符所代表的资源,其特权级至少要等于这个门槛。
- 在计算机中真正的访问者是硬件CPU,而指挥CPU行为的是具有可执行能力的指令代码,所以访问者就是代码段中的指令。
- 对于受访者为数据段(段描述符中type字段中未有X可执行属性):只有访问者的权限>=DPL才可访问。例如:若DPL = 1,则只有特权级为0和1的才可以访问。
- 对于受访者为代码段(段描述符中type字段中含有X可执行属性):只能平级访问,特权级> < DPL的都不行,只有特权级 = DPL的才可以访问。对于受访者为代码段而言,实际上是指处理器从当前运行的代码段上转移到受访者这个目标代码段上去执行。
- 代码段不平级运行的方法:
- 唯一一种CPU会从高特权级->低特权级运行的情况:CPU从中断处理程序中返回到用户态的时候。中断处理是在0特权级下运行的,因为中断的发生多半是外部硬件发生了某种状况/不可抗事件而必须通知CPU导致的,所以中断的处理过程中需要具备访问硬件的能力。再者,有些中断处理中需要的指令只能在0特权级下使用,这部分指令被称为特权指令。在运行用户程序时若发生了中断,CPU会暂停用户程序的执行,随后CPU会自动由3特权级进入0特权级,在0特权级下将执行用户程序时的现场环境(上下文)保存起来,待中断处理完成后,CPU会恢复用户程序的执行,回到3特权级。
- CPU提供了几种用于从低特权级->高特权级的方法:
- 一致性代码:
- 一致性代码段是指如果自己是转移后的目标段,自己的特权级(DPL)一定要>=转移前的CPL。也就是说一致性代码段的DPL是权限的上限,任何在此权限之下的特权级都可以转到此代码段上执行。
- 一致性代码的特点是转移后的特权级不与自己的特权级(DPL)为主,而是与转移前的低特权级一致,听从、依从转移前的低特权级,并不会将CPL用目标段的DPL替换。
- 在一致性代码的特权级检查中,RPL不参与。特权级检查发生在访问者访问受访者的一瞬间,只检查一次,检查过后,在该段上以后的执行过程中再也不会被检查。
- CPU通过“门结构”从低特权级->高特权级。
- 一致性代码:
4. 门
- RPL的产生主要是为了解决系统调用时的“缺权”问题,系统调用的实现方式中,以调用门和中断门最为合适。
- 门结构是记录一段程序起始地址的描述符。
- 门描述符同段描述符类似,都是8字节大小的数据结构,用来描述门中通向的代码。
- 门描述符一共有4种:任务门描述符、中断门描述符、陷阱门描述符和调用门描述符。
- 门描述符与段描述符最大的不同在于,除了任务门外,其他三种门都对应到一段例程,即对应一段函数,而不是像段描述符对应一片内存区域。例程是用选择子+段内偏移量来描述的。
- 任务们可以位于GTDT、LDT和IDT(中断描述符表)中,调用门可以位于GDT和LDT中,中断门和陷阱门位于IDT中。
- 任务们和调用门可以用call和jmp指令直接调用,因为这两个门描述符都位于描述符表中,要么是GDT,要么是LDT,访问它们是普通的段描述符是一样的。所以只要在call和jmp指令后面接任务门/调用门的选择子便可调用它们。
- 陷阱门和中断门只位于IDT中,因此不能主动调用,只能由中断信号来触发调用。
- 门的调用:
- 调用门:call和jmp指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权级->高特权级转移,可以来实现系统调用。call指令使用调用门可以实现向高特权级代码转移,jmp指令使用调用门只能向平级特权代码转移。
- 中断门:以int指令主动发中断的形式从低特权级->高特权级转移,Linux的系统调用便是以中断门实现的。
- 陷阱门:以int3指令主动发中断的形式实现从低特权级->高特权级转移,这一般是编译器在调试时用的。
- 任务门:以任务状态段TSS为单位,用来实现任务切换,它可以借助中断/指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用call/jmp指令后接任务门的选择子/任务TSS的选择子。
- 门的“门槛”是访问者特权级的下限,访问者的特权级再低也不能比门描述符的DPL低,否则连门都进不去,更谈不上调用门。
- 门的“门框”是访问者特权级的上限,访问者的特权级再高也不能比门描述符中目标程序所在的代码段的DPL高,否则本身的特权级就比目标代码特权级高了,还要门干什么?
- 在使用门结构前,CPU要例行公事做特权级检查,参与检查的是CPL、DPL和RPL。
- 处理器从一个特权级转移到另一个特权级,任何时刻CPU所处的特权级称为当前特权级CPL。CPL变换的原因是CPU从某一特权级的代码段转移到另一特权级的代码段上运行,代码段的特权级DPL就是未来CPU的CPL。
- 各种门结构存在的目的就是为了让CPU提升特权级,这样CPU才能完成一些低特权级无法完成的任务。
5. 调用门
- 调用门结构
- OS可以利用调用们实现一些系统功能(现代OS一般用中断门实现系统调用),用户程序需要系统服务时可以调用该调用门以获得内核帮助。
- 既然门描述符用来指向某个内核例程,是例程就需要参数,那么在用户3特权级下如何向0特权级下的内核传递参数呢?不同特权级下CPU用不同的栈,在用户进程下,参数压入3特权级的栈。为了让0特权级的内核可以使用参数,CPU在硬件上实现参数的自动复制,将用户进程压在3特权级栈中的参数自动复制到0特权级栈中。
- 在图中,调用门结构中有个“参数个数”的变量,这是CPU将用户提供的参数复制到内核时需要用到的。因为参数在栈中是挨着的,所以只需要知道个数即可。
- OS可以利用调用们实现一些系统功能(现代OS一般用中断门实现系统调用),用户程序需要系统服务时可以调用该调用门以获得内核帮助。
- 调用门的过程保护
- 调用门涉及两个特权级,一个是转移前的低特权级,也就是程序调用“调用门”时的CPL;另一个是转移后的目标特权级,这是由门描述符中选择子对应的目标代码段的DPL决定的。
- call指令调用“调用门”的完整过程:该门描述符中参数个数为2,即用户进程要为调用门提供2个参数。调用前的当前特权级为3,调用后的新特权级为0。
- 为此调用门提供2个参数,这是在使用调用门之前完成的。目前是3特权级,所以要在3特权级栈中压入参数。
- 确定新特权级使用的栈。新特权级就是未来的CPL,也就是转移后的目标代码段的DPL。CPU自动在TSS中找到合适的栈段选择子ss和栈指针esp作为转移后的新栈。记作SS_new和ESP_new。
- 检查新栈选择子对应的描述符的DPL和TYPE,如果未通过检查则CPU引发异常。
- 若转移后的目标代码段的DPL比CPL高,说明需要特权级转换。将旧栈段选择子SS_old和ESP_old保存到新栈中,这样在高特权级的目标程序执行完后才能通过retf返回旧栈中。但因为只有使用新栈后才能将SS_old和ESP_old保存到新栈中,所以CPU先找个地方存放SS_old和ESP_old,再将SS_new加载到栈段寄存器SS,ESP_new加载到栈指针寄存器ESP,开启新栈。
- 在使用新栈后,将上一步中临时保存的SS_old和ESP_old压入新栈中【用于恢复栈,如果是平级转移则不需要保存旧栈,因为压根不需要换栈】。
- 将用户栈中的参数也复制到新栈中,根据“参数个数”决定复制的参数。
- 由于调用门描述符中记录的是目标程序所在的代码段的选择子和偏移地址,这意味着代码段寄存器CS要用该选择子重新加载。只要栈寄存器被加载,段描述符缓存寄存器就会被刷新,相当于切换到了新段上运行,这是段间远转移。所以要将当前代码段CS和EIP都备份在栈中,分别记作CS_old和EIP_old。这两个值是恢复用户进程的关键,也就是从内核进程返回时用的地址【用于恢复代码段】。
- 一切就绪,只差运行调用门中指向的程序了。把门描述符中的代码段选择子装载到代码段寄存器CS中,然后把偏移量装载到指令指针寄存器EIP中。至此,CPU终于从用户程序转移到内核程序上,实现了特权级由3到0的转移。
- 如何从高特权级返回低特权级?用retf指令将返回地址从栈中弹出到CS和EIP,将低特权级栈地址弹出到SS和ESP中。
- 利用retf指令从调用门返回的过程:
- 将CPU执行retf时,知道这是远返回,所以需要从栈中返回旧栈的地址及返回到低特权级的程序中。这时它要进行特权级检查,先检查栈中CS选择子,根据其RPL位,即未来的CPL,判断在返回过程中是否需要改变特权级。
- 此时栈顶应该指向EIP_old,从栈顶弹出CS_old和EIP_old。根据CS_old选择子对应的代码段的DPL及选择子中的RPL做特权级检查。若检查通过,则将EIP_old弹出到寄存器EIP,CS_old的低16位弹出到寄存器CS中。
- 如果返回指令retf后面有参数,则增加栈指针ESP_new的值,以跳过栈中参数。按理说retf+参数就是为了跳过低特权级复制到高特权级栈中的参数,所以retf后面的参数应该等于参数个数*参数大小。
- 如果第一步中判断出需要改变特权级,则将ESP_old弹出到寄存器ESP中,SS_old的低16位弹出到寄存器SS中,恢复旧栈。
- jmp只能用在不需要特权级变化,且不从调用门返回的场合。
6. RPL
- 为了系统安全必须保证的两个客观条件:
- 用户不能访问系统资源,不能越俎代庖去做OS的事情。
- CPU必须要陷入内核才能帮助用户程序做"大事",所以CPU的当前特权级会变成至高无上的0特权级。
- 因此,受访者不知道访问者的真实身份(OS还是用户程序),只能知道它的特权级。
- RPL为请求特权级,它代表真正请求者的特权级。以后在请求某特权级为DPL级别的资源时,CPL和RPL的特权必须同时大于等于受访者的DPL。
- 用户程序的CPL不可伪造,它起始是由OS在加载用户程序时赋予的,记录在段寄存器CS中的低2位,也就是RPL的位置,而CS寄存器只能通过call、jmp、ret、int和sysenter等指令修改。如果用户程序申请了OS服务,如果它提交选择子做参数,选择子中的RPL也会被OS修改为用户进程的CPL。
- arpl指令可以修改选择子中的RPL。指令格式:arpl 同样寄存器/16位内存,16位通用寄存器。
实际此指令操作数变成了:arpl 用户提交的选择子,用户段寄存器CS的值 - 特权级检查在什么时候发生?如何被触发?
- CPU的特权检查,都只发生在往段寄存器中加载选择子访问描述符的那一瞬间。
- 若不通过调用门、直接访问一般代码时的特权检查规则:
- 若目标为非一致性代码,则CPL = RPL = 目标代码段的DPL。
- 若目标为一致性代码,则特权级的CPL和RPL <= DPL(特权级,非数值)。
- 有关代码的特权检查,都发生在能够改变代码段寄存器CS和指令指针寄存器EIP的指令中。例如:call、jmp、ret 和 sysexit等。
- 若不通过调用门、直接访问一般数据时的特权检查规则:
- CPL和RPL在特权上 >= 目标数据段的DPL
- 特权级检查会发生在向数据段寄存器中加载段选择子时。数据段寄存器包括DS和附加段寄存器ES、FS和GS。
- 栈段的检查比较特殊,因为在各特权级下,CPU都要对应的栈,所以栈的等级要与CPL相等。所以往段寄存器SS中赋予数据段选择子时,CPU要求CPL = RPL = 栈的目标数据段的DPL。
- 例如:mov ds,ax时便会触发特权级检查。ax中的值被当作选择子,CPU会拿ax中的低2位,即RPL和CPL分别与ax中选择子所指向的段描述符DPL作比较。如果满足RPL和CPL等级都 >= DPL时,选择子才会被加载到DS中。
- CPL和RPL的区别:
- CPL是对当前正在运行的程序而言的,而RPL有可能是正在运行的程序,也可能不是。
- 如果低特权级不向高特权级程序提供自己特权级下的选择子,也就是不涉及向高特权级程序“委托、代理"办事的话,CPL和RPL都出自同一程序。
- 如果低特权级向高特权级“委托、代理"办事的话,CPL是指代理人,即内核;RPL则有可能是委托者,即用户程序,也可能是内核自己。
- 例如:若用户程序运行在3特权级,它想通过调用门读取硬盘上某个文件到它自己的数据缓冲区中。它需要提供3个参数:文件所在的硬盘扇区号、用于存储文件的缓冲区所在的数据段选择子以及缓冲区的偏移地址。用户进程只能把与自己同一特权级的数据段作为缓冲区,所以该缓冲区所在的段的DPL为3,其选择子的RPL为3。进入调用门后,CPU的CPL由运行用户进程时的3变为内核态的0。当内核从硬盘上读取完数据后,需要将其写入用户的缓冲区中,缓冲区的选择子是由用户提供的,其RPL为3,缓冲区所在段的DPL为3,此时CPL为0,所以写入成功。RPL是用户进程提供的,而往缓冲区中写数据时CPL指的是内核,不是同一个程序。
- 调用门的特权级检查:
- CPL在DPL_GATE和DPL_CODE之间。
- RPL只在进调用门时和DPL_GATE比较一下,不参与和DPL_CODE比较。因为RPL不代表真正的请求者。
- 假如用户程序想获取安装的物理内存大小,流程为:
- 因为用户进程的CPL = 3,不能访问DPL = 0的数据段,所以使用调用门,OS将调用门的DPL设置为3让用户进程进入门。该调用门让用户进程提交用于存储系统内存容量的缓冲区所在数据段的选择子和偏移地址。
- 用户进程的CPL与门描述符中选择子所对应的代码段描述符DPL比较,发现特权级CPL <= DPL,通过。此时CPL变为0,CPU以0特权级身份开始执行内核程序。
- 为了安全起见,OS将用户选择子的RPL变为用户进程的CPL,即指向缓冲区所在段的RPL = 3。
7.IO特权级
- 在保护模式下,CPU中的阶级不仅体现在代码和数据的访问中,还体现在指令中。
- 特权指令:特权指令的执行对计算机有着严重的影响,它们只能在0特权级下被执行。
- IO敏感指令:IO读写特权是由标志寄存器eflags中的IOPL位和TSS中的IO位图决定的,它们用来指定执行IO操作的最小特权级。IO相关的指令只有在当前特权级>=IOPL时才能执行,所以它们被称为IO敏感指令。
- 用户进程可以访问IO端口,只是OS不允许用户进行这么做罢了。所以我们平时被灌输的思想就是用户进程无法直接访问硬件,必须借助OS的帮助。只有OS才可以访问外设,OS的职责就是管理计算机中的资源,包括软件和硬件。不允许用户进程直接访问外设,这是OS对计算机的保护。
- eflags寄存器中的12~13位是IOPL,即IO特权级。它除了限制当前任务进行IO敏感指令的最低特权外,还用来决定任务是否允许操作所有IO端口。IOPL是打开所有IO端口的开关,每个任务(内核进程/用户进程)都要自己的eflags寄存器。每个任务的IOPL位表示当前任务想要执行全部IO指令的最低特权级,即CPU的最低CPL。
- IO位图
- 如果CPL特权级<IOPL,也可以通过IO位图来设置部分端口的访问权限,若位 = 0表示可以访问,= 1 表示禁止访问。
- 优点:提高速度。如果所有IO端口访问都要经过内核的话,由低特权级->高特权级时需要保存上下文环境,消耗处理器时间。
- IO位图只在特区级CPL < IOPL时才生效。
- IO位图位于TSS中,可有可无,若无则表示禁止访问所有端口。
- IO位图大小:一共有65536个IO端口,一共需要65536/8=8192B。
- TSS中偏移102自己处,占2字节空间的地方,用来存储IO位图的偏移地址。如果IO位图存在的话,它位于TSS的顶端。
- TSS的实际尺寸并不固定,若存在IO位图,则TSS大小 = IO位图偏移地址 + 8192 + 1字节,结尾1B是最后的0xff;若无,则TSS大小 = 104字节。
- 0xFF的作用:
- 若全部位都是1,则表示禁止访问任何端口。
- 用作界限符,防止越界。
8. 总结
- CPL不是一个单独的东西,它只是CS段寄存器中存储的选择子的RPL。也就是说当前运行的代码段(CS段寄存器中存储的选择子)的RPL其实就是所谓的CPL。
- 特权级检查只发生在向段寄存器中加载选择子的时候,也就是call 函数 / mov ds/es/fs/gs…指令的时候。即需要比较CPL(CS.RPL)和需要用到的代码段/数据段描述符的DPL。
- 当特权级检查通过后,CS_new会上CPU,即DPL_new会替换掉RPL。
- 当调用中断门时,首先要比较CPL和中断门描述符的DPL,CPL特权级要高于DPL,表示可以进入中断门。然后要比较CPL和中断门描述符中的中断处理函数的DPL,CPL特权级要低于DPL,不然没必要调用中断门。
- 如果用户要读取内核的数据,用户需要提供给调用门一个接收内核数据的缓冲区,这个缓冲区必然是某个数据段描述符,所以用户提供的是选择子。若该选择子中的RPL特权级低于该段描述符的DPL,则说明是内核数据,用户不能修改,所以出错;若该选择子的RPL特权级高于/等于该段描述符的DPL,说明是用户数据,内核会将内核数据写入到该缓冲区中。