1. 部署工作环境
mac的部署环境
安装bochs,因为看了很多贴纸,发现mac安装这个很简单,直接brew就可以
brew install bochs
安装后,在安装目录的/share/doc/bochs
下有个sample文件bochsrc-sample.txt
可以参考这个在目录下写一个 disk
bochsrc.disk:
# Bochs启动配置文件
# 1.Bochs在运行中可使用的内存,设为32MB
megs: 32
# 2.设置对应真实机器的BIOS和VGA BIOS; 须为绝对路径,Bochs不识相对路径
romimage: file=/usr/local/Cellar/bochs/2.7/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/local/Cellar/bochs/2.7/share/bochs/VGABIOS-lgpl-latest
# 3.选择启动盘符为硬件启动
boot: disk
# 4.日志输出
log: bochs.out
# 5.关闭鼠标,打开键盘
mouse: enabled=0
keyboard: keymap=/usr/local/Cellar/bochs/2.7/share/bochs/keymaps/x11-pc-us.map
# 6.硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63
# ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63
#没有正确的加载Bochs GUI,上面使用term_gui
display_library: sdl2
bin/bximage
创建虚拟硬盘
根据提示就可以创建,然后根据这个提示来加入disk里面的配置
效果就是
2. 编写MBR主引导记录
当按下主机上的power键后,第一个运行的软件是BIOS,书中有提了三个问题,也是这章所要了解的问题
- 它是由谁加载的
- 它被加载到哪里
- 它的cs:ip是谁来更改的
BIOS
BIOS全称为Base Input & OutPut System 基本输入输出系统
BIOS的主要的工作是检测,初始化硬件,由于硬件提供了一些初始化的功能调用,所以BIOS直接调用就可以,BIOS建立了中断向量表,从而可以通过int中断号来实现相关的硬件调用
BIOS 建立的这些功能就是对硬件的 IO 操作,也就是输 入输出,但由于就 64KB 大小的空间,不可能把所有硬件的 IO 操作实现得面面俱到,所以只需要挑选重要的,保证计算机能运行的那些硬件的基本IO操作就OK了,所以BIOS叫做基本输入输出系统
BIOS如何执行
BIOS为计算机第一个运行的软件,所以不能自己加载自己,所以是由硬件加载的,BIOS本身是个程序,入口地址是0xFFFF0,CPU中的cs:ip 为0xF000:0xFFF0,因为cpu访问内存是由段地址+偏移地址来实现的
证明了0xFFFF0的内容是一条跳转指令,cpu真正执行的位置就是0xfe05b,上面的图可以看到cs = 0xf000,就是加电的时候强制把cs置为了0xf000
接下来 BIOS 检测内存、显卡等外设信息,当检测通过,并初始化好硬件后,开始在内 存中 0x000~0x3FF 处建立数据结构,中断向量表 IVT 并填写中断例程
0x7c00
BIOS最后一项工作的校验启动盘位于0盘0道1扇区的内容,但是本质上就是0盘0道0扇区,0盘代表了0磁头,0道代表了0柱面,因为磁道是圆环形状的,被划分出来的小区间就是扇形,叫做扇区
扇区末尾的两个字节分别是魔数0x55和0xaa,BIOS便认为此扇区中存在可执行的程序,加载到物理地址0x7c00,BIOS跳转到0x7c00是用jmp 0:0x7c00实现的,cs会被替换从0xf000变成0x0
如果扇区的最后2个字节不是0x55,0xaa,那么BIOS不会认,因为会把扇区当作没格干净处理了
“0x7C00”最早出现在 IBM 公司出产的个人电脑 PC5150 的 ROM BIOS 的 INT19H 中断处理程序中,在这台计算机上,运行的操作系统是 DOS 1.0此版本 BIOS 是按最小内存 32KB 研发的,按DOS 1.0要求的最小内存32KB来说,MBR希望给人家尽可能多的预留空间,免得过早被覆盖,所以 MBR 只能放在 32KB 的末尾,MBR 本身也是程序,是程序就要用到栈,栈也是在内存中的,MBR 虽然本身只有 512 字节,但还要为其所用的栈分配点空间,所以其实际所用的内存空间要大于 512 字节,估计 1KB 内存够用了,32KB 换算为十六进制为 0x8000,减去 1KB(0x400)的话,等于 0x7c00
MBR
SECTION MBR vstart=0x7c00 ;编译的起始地址为0x7c00
mov ax,cs ;由于cs在第一次跳转到0x7c00的时候进行了初始化,等于了0,CPU不能直接给他们赋值
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ;初始化栈
;清除屏幕的其他字符串,0x06号功能
mov ax, 0x600 ;AH = 0x06 AL = 0x0代表功能号为6,0代表了上卷的行数为全部
mov bx, 0x700 ;上卷行属性
mov cx, 0 ;左上角(0,0)
mov dx, 0x184f ;右下角(80,25)VGA模式一行80,一共25行是极限
int 0x10 ;中断
;获取光标位置,3号子功能获取光标位置,bh存储了待获取光标的页号
mov ah, 3
mov bh, 0
int 0x10
;打印字符串
mov ax, message
mov bp, ax
mov cx, 0xb ;长度
mov ax, 0x1301 ;ah = 0x13为显示字符和属性,al = 0x1 属性为显示字符串,光标跟随移动
mov bx, 0x2 ;bh代表了显示的页号,bl代表了字符的颜色和北京
int 0x10
jmp $
message db "Hello World"
times 510-($-$$) db 0
db 0x55,0xaa
“vstart=0x7c00”表示本程序在编译时,告诉编译器,把我的起始地址编译为 0x7c00
cs寄存器的值去初始化其他寄存器。由于BIOS是通过jmp 0:0x7c00跳转到MBR的,因为cs 此时为 0。对于 ds、es、fs、gs 这类 sreg,CPU 中不能直接给它们赋值,没有从立即数到段寄存器的电路实现, 只有通过其他寄存器来中转,这里我们用的是通用寄存器 ax 来中转,例如 mov ds:0x7c00,这样就错了
nasm -o mbr.bin mbr.S
dd if=/Users/l0x1c/Code/os/boot/mbr.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 conv=notrunc
bs:统计配置了输入块的大小是多少字节
count:拷贝的块数
conv:conv 最好用 notrunc 方式,也就是不打断文件
3. 完善MBR
地址,section,vstart
地址:
地址来说就是一个数字,代表了数据的位置关系,这些都是编译器来做的事情,总体来说,编译器给程序中的变量名,函数名分配的地址,就是各个符号相对于文件开头的偏移量,书中的这个图不错
可以看到上面的图中,因为没有定义section vstart的位置,所以$$代表了section,起始的位置不知道,因为没有定义section,所以nasm默认把全体文件当成一个大的section,所以全体文件的自然偏移地址等于0
继续观察的话,由于var的地址在0xd,所以mov ax, [var]的反汇编代码就是mov ax, [0xd]
地址无非就是上一个的数据长度,累加,比如第一句的时候数据为B80000所以是3字节,所以第二句话的起始位置就在0x3
section:
section被称为节,在我的理解中,section的含义主要是进行分类,比如哪里是code代码,哪里是data代码等等,这样可以让代码的结构比较清晰,容易维护
section来说并没有对程序的地址产生了什么影响,默认下有没有section都可以,section的数据地址依然是对文件的顺延,就是比如 jmp label这里并不是单句循环自己,那么下面的 section data将会被当作代码进行执行,所以section来说是在逻辑上让开发比较好梳理整个程序
vstart:
section用vstart= 来修饰后,可以赋予一个virtual start address,vstart 的作用是为 section 内的数据指定一个虚拟的起始地址,也就是根据此地址,在文件中是找不到相关数据的,是虚拟的,假的,文件中的所有符号都不在这个地址上
vstart 只是按照开发人员的意愿安排新的起始地址,不再以文件开头 0 为起始,其地址若 超过文件大小则不会落在文件内,所以是虚拟的
CPU的工作原理
CPU大体分为3个部分,控制单元,运算单元,存储单元
控制单元大致由指令寄存器 IR(Instruction Register)、指令译码器 ID(Instruction Decoder)、操作控制器 OC(Operation Controller) 组成
当程序加载到内存中,指令寄存器IP指向内存中下一条待执行指令的地址,将内存中的指令装载到指令寄存器中,指令译码器根据指令寄存器中的指令按照格式进行解码,分析出操作码和操作数是什么
指令中用到的数据存放到存储单元中,存储单元是指的CPU内部的L1,L2缓存以及寄存器,疑问就是数据在内存中,为什么还要在CPU内部搞存储单元?因为缓存用的是SRAM存储器,我们插在主板上的是DRAM,DRAM需要隔一段时间去刷新电路,刷新就是指给DRAM充电,否则存储的数据会丢失,但是SRAM不需要刷新电路就可以保存内部存储的数据,但是SRAM集成很低,SRAM对比DRAM体积会变得大很多
关系图:
控制单元要取下一条待运行的指令,该指令 的地址在程序计数器 PC 中,也就是cs:ip,读取 ip 寄存器后,将此地址送上地址 总线,CPU 根据此地址便得到了指令,并将其存入到指令寄存器 IR 中,然后指令译码器根据 指令格式检查指令寄存器中的指令,先确定操作码是什么,再检查操作数类型,若是在内存中,就将相应操作数 从内存中取回放入自己的存储单元,若操作数是在寄存器中就直接用了,操作码有了, 操作数也齐了,操作控制器给运算单元下令,开工,于是运算单元便真正开始执行指令了,ip 寄存器的值被加上 当前指令的大小,于是 ip 又指向了下一条指令的地址从而进行循环
实模式下的寄存器
CPU 中的寄存器大致上分为两大类:
一类是其内部使用的,对程序员不可见。“是否可见”不是说寄存器是否能看得见,是指程序员是否能使用。CPU 内部有其自己的运行机制,是按照某个预定框架进行的,为了 CPU 能够运行下去,必然 会有一些寄存器来做数据的支撑,给 CPU 内部的数据提供存储空间。这一部分对外是不可见的,我们无 法使用它们,比如全局描述符表寄存器 GDTR、中断描述符表寄存器 IDTR、局部描述符表寄存器 LDTR、 任务寄存器 TR、控制寄存器 CR0~3、指令指针寄存器 IP、标志寄存器 flags、调试寄存器 DR0~7
另一类是对程序员可见的寄存器。我们进行汇编语言程序设计时,能够直接操作的就是这些寄存器,如段寄器、通用寄存器
段有很多种:
- CS代码段,把所有指令都连续放在一起,是一个只有指令的区域
- DS数据段,类似CS段,不过存的都是数据
- SS栈段,只在内存中有
- ES、FS、GS附加段,给程序提供的多几个段来用
通用寄存器:
标志寄存器
第0位的是CF位,即Carry Flag,意为进位。运算中,数值的最高位有可能是进位,也有可能是借 位,所以 carry 表示这两种状态。不管最高位是进位,还是借位,CF 位都会置 1,否则为 0。它可用于检 测无符号数加减法是否有溢出,因为 CF 为 1 时,也就是最高位有进位或借位,肯定是溢出
第 2 位为 PF 位,即 Parity Flag,意为奇偶位。用于标记结果低 8 位中 1 的个数,如果为偶数,PF 位 为 1,否则为 0。注意啦,是最低的那 8 位,不管操作数是 16 位,还是 32 位。奇偶校验经常用于数据传 输开始时和结束后的对比,判断传输过程中是否出现错误
第4位为AF位,即Auxiliary carry Flag,意为辅助进位标志,用来记录运算结果低4位的进、借位 情况,即若低半字节有进、借位,AF 为 1,否则为 0
第 6 位为 ZF 位,即 Zero Flag,意为零标志位。若计算结果为 0,此标志为 1,否则为 0
第 7 位为 SF 位,即 Sign Flag,意为符号标志位。若运算结果为负,则 SF 位为 1,否则为 0
第 8 位为 TF 位,即 Trap Flag,意为陷阱标志位。此位若为 1,用于让 CPU 进入单步运行方式,若为 0,则为连续工作的方式。平时我们用的 debug 程序,在单步调试时,原理上就是让 TF 位为 1。可见,软 件上的很多功能,必须有硬件的原生支持才能得以实现
第9位为IF位,即Interrupt Flag,意为中断标志位。若IF位为1,表示中断开启,CPU可以响应外部可屏蔽中断。若为 0,表示中断关闭,CPU 不再响应来自 CPU 外部的可屏蔽中断,但 CPU 内部的异常 还是要响应的,因为它关不住
等等
实模式被淘汰的原因,也是因为有安全隐患,在实模式下,可以执行一些具有破坏性的指令
改进MBR,直接操作显卡
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax
;清除屏幕的其他字符串,0x06号功能
mov ax, 0x600
mov bx, 0x700
mov cx, 0
mov dx, 0x184f
int 0x10
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4
mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4
mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4
mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4
mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
jmp $
times 510-($-$$) db 0
db 0x55,0xaa
硬盘工作原理
硬盘的盘片分上下两面,每面都存储数据,每个盘面都各有一个磁头来读取数据,一个盘片对应2个磁头,由于盘面与磁头是一一对应关系,所以用磁头号代表盘面号,磁头被固定在右边的磁头臂,运动轨迹是个弧线,并不是直线,一方面盘片的自转,另一方面磁头的摆动,这两种动作的合成,使磁头能够读取盘 片任意位置的数据
硬盘控制器端口
Control Block registers 用于控制硬盘工作状态
in指令用于从端口中读取数据,形式:
in al, dx
in ax, dx
其中al,ax用来存储从端口获取的数据,dx指的是端口号,这是固定用法,只要用 in 指令,源操作数(端口号)必须是 dx,而目的操作数是用 al,还是 ax,取决于 dx 端口指代的寄存器是 8 位宽度,还是 16 位宽度
out 指令用于往端口中写数据,其一般形式是:
out dx, al
out dx, ax
out 立即数, al
out 立即数, ax
和 in 指令相反,in 指令的源操作数是端口号,而 out 指令中的目的操作数是端口号
0x1f7端口寄存器用来存储让硬 盘执行的命令,只要把命令写进此寄存器,硬盘就开始工作了
主要的三个命令:
identify:0xEC,即硬盘识别
read sector:0x20,即读扇区
write sector:0x30,即写扇区
LBA介绍:LBA是用来描述扇区地址的,LBA low、LBA mid、LBA high 三个寄存器用来表示LBA的24位,LBA 28其中还缺少的4位是用,device 这个杂项寄存器在此寄存器的低 4 位用来存储 LBA 地址 的第 24~27 位,第 4 位用来指定通道上的主盘或从盘,0 代表主盘,1 代 表从盘。第 6 位用来设置是否启用 LBA 方式,1 代表启用 LBA 模式,0 代表启用 CHS 模式。另外的两位: 第 5 位和第 7 位是固定为 1 的,称为 MBS 位
步骤:
(1)先选择通道,往该通道的 sector count 寄存器中写入待操作的扇区数
(2)往该通道上的三个 LBA 寄存器写入扇区起始地址的低 24 位
(3)往 device 寄存器中写入 LBA 地址的 24~27 位,并置第 6 位为 1,使其为 LBA 模式,设置第 4位,选择操作的硬盘(master 硬盘或 slave 硬盘)
(4)往该通道上的 command 寄存器写入操作命令
(5)读取该通道上的 status 寄存器,判断硬盘工作是否完成
(6)如果以上步骤是读硬盘,进入下一个步骤,否则,完工
(7)将硬盘数据读出
%include "boot.inc"
SECTION MBR vstart=0x7c00 ;告诉编译器,起始地址是0x7c00
mov ax,cs ;因为BIOS执行完毕后cs:ip为0x0:0x7c00,所以用cs初始化各寄存器(此时cs=0)
mov ds,ax ;ds、es、ss、fs不能给立即数初始化,需要用ax寄存器初始化
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ;初始化堆栈指针,因为目前0x7c00以下的内存暂时可用
mov ax,0xb800 ;选择显卡的文本模式
mov gs,ax ;使用GS段寄存器作为显存段基址
mov ax,0x600 ;上卷行数:全部 功能号:06
mov bx,0x700 ;上卷属性
mov cx,0 ;左上角:(0,0)
mov dx,0x184f ;右下角:(80,25)
;VGA文本模式中,一行只能容纳80字节,共25行
;下标从0开始,所以0x18=24,0x4f=79
int 0x10 ;int 0x10
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4
mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4
mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4
mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4
mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
mov eax,LOADER_START_SECTOR ;起始扇区lba地址
mov bx,LOADER_BASE_ADDR ;写入的地址
mov cx,1
call rd_disk_m_16
jmp LOADER_BASE_ADDR
;功能读取硬盘n个扇区
rd_disk_m_16:
;eax = LBA扇区号
;bx = 将数据写入的内存地址
;cx = 读入的扇区数
mov esi ,eax ;备份eax
mov di ,cx ;备份cx
;读写硬盘
;1---设置要读取的扇区数
mov dx ,0x1f2 ;设置端口号,dx用来存储端口号的
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 ,8
shr eax ,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
mov dx ,0x1f4
out dx ,al
;LBA 24~16位写入端口0x1f5
shr eax ,cl
mov dx ,0x1f5
out dx ,al
shr eax ,cl ;device 寄存器中写入 LBA 地址的 24~27 位,并置第 6 位为 1,使其为 LBA 模式,设置第 4位,选择操作的硬盘(master 硬盘或 slave 硬盘)
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 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al ,0x08
jnz .not_ready
;5---0x1f0端口读取数据
mov ax ,di ;要读取的扇区数
mov dx ,256 ;一个扇区512字节,一次读取2字节,需要读取256次
mul dx ;结果放在ax里
mov cx ,ax ;要读取的次数
mov dx ,0x1f0
.go_on_read:
in ax, dx
mov [bx], ax ;bx是要读取到的内存地址
add bx, 0x02
loop .go_on_read ;循环cx次
ret
times 510-($-$$) db 0
db 0x55,0xaa
LOADER_BASE_ADDR代表了loader在内存中的位置,设置的是0x900,那么loader会在内存地址0x900的位置
LOADER_START_SECTION定义了loader在硬盘上的逻辑扇区的地址,因为等于2,那么loader放在第2块扇区
因为我们只读区一个扇区,所以cx=1,定义了bx是后面的data进行读取到0x900的位置
上面的所有out in的操作都是再往硬盘的那些寄存器中写入数据,比如:mov dx ,0x1f2 ;设置端口号,dx用来存储端口号的 out dx ,al 就在往sector count寄存器中写入扇区的数量
实现内核加载器
%include "boot.inc"
SECTION loader vstart=LOADER_BASE_ADDR
mov byte [gs:0x00],'2'
mov byte [gs:0x01],0xA4
mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4
mov byte [gs:0x04],'L'
mov byte [gs:0x05],0xA4
mov byte [gs:0x06],'O'
mov byte [gs:0x07],0xA4
mov byte [gs:0x08],'A'
mov byte [gs:0x09],0xA4
mov byte [gs:0x0A],'D'
mov byte [gs:0x0B],0xA4
mov byte [gs:0x0C],'E'
mov byte [gs:0x0D],0xA4
mov byte [gs:0x0E],'R'
mov byte [gs:0x0F],0xA4
jmp $
效果:
nasm -I include -o mbr.bin mbr.S
nasm -I include -o loader.bin loader.S
dd if=/Users/l0x1c/Code/os/boot/mbr.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 conv=notrunc
dd if=/Users/l0x1c/Code/os/boot/loader.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 seek=2 conv=notrunc
cd /usr/local/Cellar/bochs/2.7
bin/bochs -f bochsrc.disk
4. 保护模式
为什么要有保护模式?
对于安全缺陷,决定用户程序可以把操作系统的数据可以被随意的修改,如果被修改后,排查不是很好的排查
访问超过64kb的内存区域需要切换段基址,一次只能运行一个程序不能充分的利用计算机资源,20根地址线最大的内存可用才1MB
所以开发了保护模式,这样物理内存不能直接被程序访问,程序内部的虚拟地址,需要被转化为物理地址后再去访问,程序对这并不清楚,地址转换通过处理器和操作系统的共同协作完成,处理器在硬件上提供了地址转换部件,操作系统提供转换过程中所需要的页表
初见保护模式
发展后主要进行了几个方面:
- 保护模式的寄存器扩展
计算机发展,需要向下兼容,所以原来的16位的寄存器需要兼容
寄存器不变的情况的问题:
如果寄存器不变还是16位,那么如果还想跟之前一样的话,就要左移16位也就是乘上65536才能访问32位的地址空间,之前的那种情况是因为当时的CPU已经成型了,避免推翻重新做,所以再背后给段基址乘上16就可以用了,就跟现在说的打patch差不多,32位的情况下,可以重新做了,所以把这种方式改了
除段寄存器外,通用寄存器、指令指针寄存器、标志寄存器都由原来的 16 位扩展到了 32 位,扩展的单词叫做extend,所以在原来的基础上加了个E
段基址不是一个简单的问题,为了安全的问题,需要加一点约束,约束的条件就是对内存段的描述信息,而这些信息,使用一个数据结构-> 全局描述符表,里面的每一个表项都叫做段描述符,全剧描述符 GDTR寄存器
所以32位的时候,我们的段寄存器中的保存不是段基址,而保存的叫做选择子,selector,选择子是个数,代表了索引全剧描述符表中的段描述符,如果全剧描述符表是数组,那么选择子就是数组的下标
段描述符的问题
段描述符在内存中,但是内存对于CPU来说比较慢,效率并不是很高
所以访问内存中的段描述符如果很耗费时间,那么CPU是不会等的,所以在保护模式的情况下,为了提高获取段信息的效率,对段寄存器率先应用了缓存技术,将段信息用一个寄存器来缓存,叫做段缓存寄存器
缓存失效时间并没有一个“准”原则,原则上在寄存器中赋值,CPU就会更新段描述符缓冲寄存器,比如保护模式下加载选择子就算值一样也会刷新的
段描述符缓冲寄存器结构:
保护模式之寻址扩展,运行模式反转,指令扩展
寻址扩展:
实模式下的寄存器有固定的作用,对于寻址一类来说,如果有想用其他的寄存器,编译斗不过,偏移量来说,只能是一个字以内的立即数,但是保护模式下就可以啦
运行模式反转:
指令0x66反转,操作数相关的
指令0x67反转,寻址方式反转
指令扩展:
保护模式下每次压入一个段寄存器,栈指针 esp 都会减 4
对于通用寄存器和内存,无论是在实模式或保护模式:
如果压入的是 16 位数据,栈指针减 2,如果压入的是 32 位数据,栈指针减 4
全局描述符表
到了保护模式的时候,内存段比如数据段,代码段的那些就不能和实模式一样了就那种加载一下段基址就可以用了,段的描述加了很多的东西,所以现在需要把段都提前的定义好,才能使用
全局描述符表(GDT)是保护模式下的内存段的登记表
段描述符
解决实模式下出现的问题:
- 实模式下的用户程序可以破坏存储代码的内存区域,所以要添加个内存段类型属性来阻止这种情况
- 实模式下的用户和操作系统是同一个级别的,所以添加个特权级属性来区分用户程序和操作系统的地位
- 内存段是一片内存区域,所以访问内存要提供段基址,所以要有段基地址属性
- 为了限制程序访问内存的属性,还要对段的大小进行约束,所以要有段界限属性
其中还有一些其他的别的属性等等,由于描述内存段的属性比较多,所以被放到了一个叫做段描述符的结构中,这个结构专门用来描述一个内存段,结构是8字节的大小也就是64位
段描述符是8字节的大小,为了方便展示被人为的分成了上面图的那种高32位,低32位,必须是连续的8字节
段描述格式符介绍
保护模式下地址总线是32位,所以段基址需要用32位地址来表示
段界限代表段边界的扩展值,有向上扩展和向下,像数据段代码段,就是向上,段界限代表段内偏移的最大值,栈是向下,G位代表段界限粒度,如果等于0表示段界限粒度大小为1字节,段界限=(描述符中段界限+1) *1 -1=描述符中段界限,那么这时候的段界限实际大小就等于描述符中的段界限值,G位为1,表示段界限粒度大小为4KB字节,实际段界限=(描述符中段界限+1)*4k-1,内存访问需要用到段基址:段内偏移地址,段界限的作用是用来限制段内偏移地址的
S位代表是系统段,还是非系统段,因为在CPU眼中分为两个大类,要么描述是系统段,要么描述的是数据段,这些都是由S位决定,他来描述是否是系统段,在CPU眼中硬件运行需要用到的东西叫做系统,软件(比如操作系统也属于软件,CPU眼中与用户程序没有区别)需要的东西都叫做数据,所以无论是代码,数据,栈它们都作为硬件的输入,都是给硬件的一个数据,所以我们所认知的代码段在段描述符中也是数据段叫做非系统段,S=0是系统 S=1是数据,type和S进行组合才有意义
8~11 位是 type 字段,该字段共 4 位,用于表示内存段或门的子类型
非系统段:
A位表示Accessed位,每当该段被CPU访问过后,CPU就会把这个位置变成1,所以,创建一个新段描述符时,应该将此位置 0
C表示是不是一致性代码段,一致性代码段,操作系统拿出来被共享的代码段,可以被低特权级的用户直接调用访问的代码,非一致性代码段,为了避免低特权级的访问而被操作系统保护起来的系统代码
R表示可读,R=1可读,R=0不可以读,一般用来限制代码段的访问
X 表示该段是否可执行,我们所说的指令和数据,在 CPU 眼中是没有任何区别的,都是 010101 这样类似的二进制。所以要用 type 中的 X 位来标识出是否是可执行的代码。代码段是可执行的, 即 X 为 1。而数据段是不可执行的,即 X 为 0
E用来表示段的扩展方向,E=0向上扩展,就是地址越来越高,经常用于代码段和数据段,E=1是向下,一般是栈
13~14 位是 DPL 字段,描述符特权级,这两位能表示 4 种特权级,分别是 0、1、2、3 级特权,数字越小,特权级越大,特权级是保护模式下才有的东西,CPU 由实模式进入保护模式后,特权级自动为 0。因为保护模式下的代码已经是操作系统 的一部分啦,所以操作系统应该处于最高的 0 特权级。用户程序通常处于 3 特权级,权限最小。某些指令 只能在 0 特权级下执行,从而保证了安全
15 位是 P 字段,Present,即段是否存在。如果段存在于内存中,P 为 1,否则 P 为 0
20 位为 AVL 字段,操作系统可以随意用此位,暂时没有什么作用
21 位为 L 字段,用来设置是否是 64 位代码段。L 为 1 表示 64 位代码段,否则表示 32 位代码段。这目前属于保留位,在我们 32 位 CPU 下编程,将其置为 0 便可
22 位是 D/B 字段,用来指示有效地址(段内偏移地址)及操作数的大小,这是为了兼容 286 的 保护模式,286 的保护模式下的操作数是 16 位
对于代码段来说,此位是 D 位,若 D 为 0,表示指令中的有效地址和操作数是 16 位,指令有效地址 用 IP 寄存器。若 D 为 1,表示指令中的有效地址及操作数是 32 位,指令有效地址用 EIP 寄存器
对于栈段来说,此位是 B 位,用来指定操作数大小,此操作数涉及到栈指针寄存器的选择及栈的地 址上限。若 B 为 0,使用的是 sp 寄存器,也就是栈的起始地址是 16 位寄存器的最大寻址范围,0xFFFF。 若 B 为 1,使用的是 esp 寄存器,也就是栈的起始地址是 32 位寄存器的最大寻址范围,0xFFFFFFFF
23 位是 G 字段,Granularity,粒度,上面介绍了
全局描述符表 GDT、局部描述符表 LDT 及选择子
段描述符们放在全局描述符表GDT里,全局描述符表 GDT 相当于是描述符的数组,数组中的每个元素都是 8 字节的描述符
全局描述符表位于内存中,需要用专门的寄存器指向它后才知道在哪里,寄存器是GDTR,用来储存GDT的内存地址和大小,GDTR是48位的寄存器
段寄存器 CS、DS、ES、FS、GS、SS,在实模式下时,段中存储的是段基地址,即内存段的起始地址,在保护模式下时,在段寄存器中存入的是一个叫作选择子的东西— selector,由于段寄存器是 16 位,所以选择子也是 16 位
其低 2 位即第 0~1 位, 用来存储 RPL,即请求特权级,在选 择子的第2位是TI位,用来指示选择子是在GDT中,还是LDT中索引描述符。TI 为 0 表示在 GDT 中索引描述符,TI 为 1 表示在 LDT 中索引描述符,选择子的高 13 位,即第 3~15 位是 描述符的索引值,用此值在 GDT 中索引描述符。前面说过 GDT 相当于一个描述符数组,所以此选择子中 的索引值就是 GDT 中的下标
例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds:0x9 这样的内存,其过程是:0x8 的低 2 位是 RPL,其值为 00。第 2 是 TI,其值 0,表示是在 GDT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GDT 中 索引,也就是 GDT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3 个段基址部分,其值为 0x1234。CPU 将 0x1234 作为段基址,与段内偏移地址 0x9 相加,0x1234+0x9=0x123d。 用所得的和 0x123d 作为访存地址
GDT 中的第 0 个段描述符是不可用的,如果使用的选择子忘记初始化,选择子的值便会是 0,若选择到了 GDT 中的第 0 个描述符,处理器将发出异常
局部描述符表,叫 LDT,CPU 的设想,一个任务对应一个 LDT,CPU 厂商建议每个任务的私有内存段都应该放到自己的段描述符表中,该表就是 LDT,即每个任务 都有自己的 LDT,随着任务切换,也要切换相应任务的 LDT
保护模式的开关,CR0 寄存器的 PE 位
控制寄存器系列 CRx,控制寄存器是 CPU 的窗口,既可以用来展示 CPU 的内部状态,也可用于控制 CPU 的运行机制 CR0 寄存器的第 0 位,即 PE 位,Protection Enable,此位用于启用保护模式,是保护模式的开关,当打开此位后,CPU 才真正进入保护模式
进入保护模式
Boot.inc:
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
;-------------- gdt -----------------
DESC_G_4K equ 1_00000000000000000000000b ;G位的颗粒度为4K
DESC_D_32 equ 1_0000000000000000000000b ;操作数和地址大小是32位的 D位
DESC_L equ 0_00000000000000000000b ;L 不是64位代码段
DESC_AVL equ 0_00000000000000000000b ;保留的位置不用赋值
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;段界限19-16位
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;段界限19-16位
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b ;显卡段
DESC_P equ 1_000000000000000b ;表示段存在
DESC_DPL_0 equ 00_0000000000000b ;特权级: 0
DESC_DPL_1 equ 01_0000000000000b ;特权级: 1
DESC_DPL_2 equ 10_0000000000000b ;特权级: 2
DESC_DPL_3 equ 11_0000000000000b ;特权级: 3
DESC_S_CODE equ 1_000000000000b ;表示非系统段
DESC_S_DATA equ DESC_S_CODE ;表示非系统段
DESC_S_SYS equ 0_000000000000b ;表示系统段
DESC_TYPE_CODE equ 1000_00000000b ;只执行代码段
DESC_TYPE_DATA equ 0010_00000000b ;这个应该是栈段
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0xB ;显存的起始地址是0xb8000
;--------------- selector ------------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 00b
TI_LDT equ 01b
mbr.bin: 读入的扇区数进行修改
%include "boot.inc"
SECTION MBR vstart=0x7c00 ;告诉编译器,起始地址是0x7c00
mov ax,cs ;因为BIOS执行完毕后cs:ip为0x0:0x7c00,所以用cs初始化各寄存器(此时cs=0)
mov ds,ax ;ds、es、ss、fs不能给立即数初始化,需要用ax寄存器初始化
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ;初始化堆栈指针,因为目前0x7c00以下的内存暂时可用
mov ax,0xb800 ;选择显卡的文本模式
mov gs,ax ;使用GS段寄存器作为显存段基址
mov ax,0x600 ;上卷行数:全部 功能号:06
mov bx,0x700 ;上卷属性
mov cx,0 ;左上角:(0,0)
mov dx,0x184f ;右下角:(80,25)
;VGA文本模式中,一行只能容纳80字节,共25行
;下标从0开始,所以0x18=24,0x4f=79
int 0x10 ;int 0x10
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4
mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4
mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4
mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4
mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
mov eax,LOADER_START_SECTOR ;起始扇区lba地址
mov bx,LOADER_BASE_ADDR ;写入的地址
mov cx,4
call rd_disk_m_16
jmp LOADER_BASE_ADDR
;功能读取硬盘n个扇区
rd_disk_m_16:
;eax = LBA扇区号
;bx = 将数据写入的内存地址
;cx = 读入的扇区数
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,8
shr eax,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来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 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5---0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512字节,一次读取2字节,需要读取256次
mul dx ;结果放在ax里
mov cx,ax ;要读取的次数
mov dx,0x1f0
.go_on_read:
in ax,dx
mov [bx],ax ;bx是要读取到的内存地址
add bx,0x02
loop .go_on_read ;循环cx次
ret
times 510-($-$$) db 0
db 0x55,0xaa
loader.S
%include "boot.inc"
SECTION loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start
;构建gdt及其内部的描述符
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 ;limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ;预留60个描述符的位置
;段选择子
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0
;gdtr前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loadermsg db '2 loader in real.'
loader_start:
;INT 0x10 功能号:0x13 功能描述:打印字符串
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若 AL=00H 或 01H)
;CX=字符串长度
;(DH、 DL)=坐标(行、 列)
;ES:BP=字符串地址
;AL=显示输出方式
; 0— 字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置不变
;1— 字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置改变
;2— 字符串中含显示字符和显示属性。显示后,光标位置不变 ; 3— 字符串中含显示字符和显示属性。显示后,光标位置改变
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ;ES:BP=字符串地址
mov cx, 17 ;CX = 字符串的长度
mov ax, 0x1301 ;AH = 0x13 AL = 0x1
mov bx, 0x001f ;BH = 0页号为0 BL = 0x1f 蓝底粉红字
mov dx, 0x1800 ;坐标
int 0x10 ;没有进入保护模式, 实模式下的打印
;------------------------ 准备进入保护模式 ------------------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
in al, 0x92
or al, 0000_0010B
out 0x92, al
;加载GDT
lgdt [gdt_ptr]
;cr0 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 $
编译命令:
nasm -I include -o mbr.bin mbr.S
nasm -I include -o loader.bin loader.S
dd if=/Users/l0x1c/Code/os/boot/mbr.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 conv=notrunc
dd if=/Users/l0x1c/Code/os/boot/loader.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=4 seek=2 conv=notrunc
./bin/bochs -f bochsrc.disk
打开20根地址线:
加载gdt地址
设置cr0的pe位:
效果
处理器微架构简介
对于流水线,乱序执行这方面可以看我之前的笔记在github上
缓存:
什么时候能缓存?
可以根据程序的局部性原理采取缓存策略。局部性原理是:程序 90%的时间都运 行在程序中 10%的代码上
int array[100][100]; int sum = 0;
...
数组 array 元素被赋值,略 ...
for (int i=0, i<100,i++) {
for(int j=0;j<100,j++) {
sum+=array[i][j];
}
}
在时间上的局部性,循环中经常用的是sum,求和指令,在空间上的局部性,当前访问地址&array[i][j] 和相邻的地址,CPU 利用此特性,将当前用到的指令和当前位置附近的 数据都加载到缓存中,这就大大提高了 CPU 效率,下次直接从缓存中拿数据,不用再去内存中取啦
分支预测:
预测是针对有条件跳转来说的,因为不 知道条件成不成立。最简单的统计是根据上一次跳转的结果来预测本次,如果上一次跳转啦,这一次也预 测为跳转,否则不跳
最简单的方法是 2 位预测法。用 2 位 bit 的计数器来记录跳转状态,每跳转一次就加 1,直到加到最 大值 3 就不再加啦,如果未跳转就减 1,直到减到最小值 0 就不再减了。当遇到跳转指令时,如果计数器 的值大于 1 则跳转,如果小于等于 1 则不跳。这只是最简单的分支预测算法,CPU 中的预测法远比这个 复杂,不过它们都是从 2 位预测法发展起来的
Intel 的分支预测部件中用了分支目标缓冲器BTB
BTB 中记录着分支指令地址,CPU 遇到分支指令时,先用分支指令的地址在 BTB 中查找,若找到相同地址的指令,根据跳转统计信息判断是否把相应的预测分 支地址上的指令送上流水线。在真正执行时,根据实际分支流向,更新 BTB 中跳转统计信息
如果 BTB 中没有相同记录,这时候可以使用 Static Predictor,静态预测器,存储在里面的预测策略是固定写死的,它是由人们经过大量统计之后,根据某些特征总结 出来的。比如,转移目标的地址若小于当前转移指令的地址,则认为转移会发生,因为通常循环结构中都 用这种转移策略,为的是组成循环回路。所以静态预测器的策略是:若向上跳转则转移会发生,若向下跳 转则转移不发生
使用远跳转指令更新段描述符缓冲寄存器
代码段寄存器 cs,只有用远过程调用指令 call、远转移指令 jmp、远返回指令 retf 等指令间接改变, 没有直接改变 cs 的方法,如直接 mov cs,xx 是不行的。另外,之前介绍过了流水线原理,CPU 遇到 jmp 指令时,之前已经送上流水线上的指令只有清空,所以 jmp 指令有清空流水线的神奇功效
所以我们在loader.S中的SELECTOR_CODE是cs的选择子更新后,变成了32位的流水线
5. 保护模式进阶,向内核迈进
获取物理内存容量
保护模式中最好的特点就是寻址空间大,进入保护模式后有了虚拟内存和内存管理的概念,但是所有的这些都是依赖于物理内存,所以我们要在物理内存上纪念性落实
Linux获取内存的方式
Linux低版本内核中使用detect_memory函数实现获取内存的容量,本质为BIOS 0x15号中断,功能号存放位置:EAX AX
- EAX = 0xE820 遍历全部内存
- AX = 0xE801 分别检测低 15MB 和 16MB~4GB 的内存,最大支持 4GB
- AH=0x88 最多检测出64MB内存,实际内存超过此容量也按照64MB返回
利用 BIOS 中断 0x15 子功能 0xe820 获取内存
BIOS 中断 0x15 的子功能 0xE820 能够获取系统的内存布局,由于系统内存各部分的类型属性不同, BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存 信息,直到将所有内存类型返回完毕,内存信息的内容是用地址范围描述符来描述的,用于存 储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure,ARDS)
其中的 Type 字段用来描述这段内存的类型,这里所谓的类型是说明这段内存的用途,即其是可以被 操作系统使用,还是保留起来不能用
BIOS 中断只是一段函数例程,调用它就要为其提供参数
调用步骤:
- 填写好 调用前输入中列出的寄存器
- 执行中断调用 int 0x15
- 在cf位为0的情况下,返回后输出 中对应的寄存器便会有对应的结果
利用 BIOS 中断 0x15 子功能 0xe801 获取内存
于 15MB 的内存以 1KB 为单位大小来记录, 单位数量在寄存器 AX 和 CX 中记录,其中 AX 和 CX 的值是一样的,所以在 15MB 空间以下的实际内存容量 =AX*1024。AX、CX 最大值为 0x3c00,即 0x3c00*1024=15MB。16MB~4GB 是以 64KB 为单位大小来记录的, 单位数量在寄存器 BX 和 DX 中记录,其中 BX 和 DX 的值是一样的,所以 16MB 以上空间的内存实际大小 =BX*64*1024
AX和CX中的单位是1KB,而BX和DX的单位是64KB
这里有两个题:
- 为什么要分前15MB和16MB以上两个部分来展示4GB
- 为什么寄存器的结果是重复的
修改bochs配置文件中的bchsrc.disk中的内存容量参数megs
可以发现检测到的内存大小要比实际的物理内存小10MB
第一个问题:
历史遗留问题,80286 拥有 24 位地址线,其寻址空间是 16MB,当时有一些 ISA 设备要用到地址 15MB 以上的内存作为缓冲区,也就是此缓冲区为 1MB 大小,所以硬件系统 就把这部分内存保留下来,操作系统不可以用此段内存空间。保留的这部分内存区域就像不可以访问的黑洞, 这就成了内存空洞 memory hole,虽然很少能碰到这些老 ISA 设备了,但为了兼容,这部分空间还是 保留下来,只不过是通过 BIOS 选项的方式由用户自己选择是否开启,开机进入 BIOS 界面后,会有类似这样的选项:memory hole at address 15m-16m
所以实际的物理内存大小,在检测结果的基础上一定要加上 1MB
第二个问题:
Not sure what this difference between the “Extended” and “Configured” numbers are, but they appear to be identical, as reported from the BIOS.
中断的调用步骤如下:
- 将 AX 寄存器写入 0xE801
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
利用 BIOS 中断 0x15 子功能 0x88 获取内存
只能识别最大 64MB 的内存。即使内存容量大于 64MB,也只会显示 63MB
为什么只显示到 63MB 呢?因为此中断只会显示 1MB 之上的内存,不包括这 1MB,所以要自己加上1MB
中断返回后,AX 寄存器中的值,其单位是 1KB。此中断的调用步骤如下:
- 将 AX 寄存器写入 0x88
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
实战内存容量检测
mbr.S:
%include "boot.inc"
SECTION MBR vstart=0x7c00 ;告诉编译器,起始地址是0x7c00
mov ax,cs ;因为BIOS执行完毕后cs:ip为0x0:0x7c00,所以用cs初始化各寄存器(此时cs=0)
mov ds,ax ;ds、es、ss、fs不能给立即数初始化,需要用ax寄存器初始化
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ;初始化堆栈指针,因为目前0x7c00以下的内存暂时可用
mov ax,0xb800 ;选择显卡的文本模式
mov gs,ax ;使用GS段寄存器作为显存段基址
mov ax,0x600 ;上卷行数:全部 功能号:06
mov bx,0x700 ;上卷属性
mov cx,0 ;左上角:(0,0)
mov dx,0x184f ;右下角:(80,25)
;VGA文本模式中,一行只能容纳80字节,共25行
;下标从0开始,所以0x18=24,0x4f=79
int 0x10 ;int 0x10
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4
mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4
mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4
mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4
mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
mov eax,LOADER_START_SECTOR ;起始扇区lba地址
mov bx,LOADER_BASE_ADDR ;写入的地址
mov cx,4
call rd_disk_m_16
jmp LOADER_BASE_ADDR + 0x300
;功能读取硬盘n个扇区
rd_disk_m_16:
;eax = LBA扇区号
;bx = 将数据写入的内存地址
;cx = 读入的扇区数
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,8
shr eax,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来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 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5---0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512字节,一次读取2字节,需要读取256次
mul dx ;结果放在ax里
mov cx,ax ;要读取的次数
mov dx,0x1f0
.go_on_read:
in ax,dx
mov [bx],ax ;bx是要读取到的内存地址
add bx,0x02
loop .go_on_read ;循环cx次
ret
times 510-($-$$) db 0
db 0x55,0xaa
Loader.S
%include "boot.inc"
SECTION loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;jmp loader_start
;构建gdt及其内部的描述符
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 ;limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ;预留60个描述符的位置
;段选择子
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 用于保存内存容量,字节为单位
; 当前偏移 loader.bin 文件头 0x200 字节
; loader.bin 的加载地址是 0x900
; 故 total_mem_bytes 内存中的地址是 0xb00
; 将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;gdtr前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes4+gdt_ptr6+ards_buf244+ards_nr2,共 256 字节 单纯的凑够0x300
; 这里面是0xc03
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录 ARDS 结构体数量
loader_start:
; 第一种
;---------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局---------
xor ebx,ebx ;第一次调用时,ebx值要为0
mov edx, 0x534D4150 ;edx 只赋值一次,循环体中不会改变,签名标记
mov di, ards_buf ;ards结构缓冲区base
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行 int 0x15 后,eax 值变为 0x534d4150,所以要更新功能号
mov ecx, 20 ;地址范围描述符结构大小20字节
int 0x15
jc .e820_failed_so_try_e801 ;cf为1 有错误发生尝试0xe801
add di, cx ;di指向新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量
cmp ebx, 0 ;如果ebx为0而且cf等于0,说明ards全部返回
jnz .e820_mem_get_loop
;在所有ards结构中
;找出(base_add_low + length_low)的最大值,即内存的容量
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是数量
mov ebx, ards_buf
xor edx, edx
.find_max_mem_area:
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;base_add_low + length_low
add ebx, 20 ;指向缓冲区下一个
cmp edx, eax ;排序,edx始终为最大的容量
jge .next_ards
mov edx, eax
.next_ards:
loop .find_max_mem_area ;根据cx循环次数
jmp .mem_get_ok
;----------------- int 15h 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方法
; 算低15MB的内存
mov cx, 0x400 ;1kb = 0x400 byte
mul cx ;ax = cx cx为乘数
shl edx, 16
and eax, 0x0000FFFF
or edx, eax
add edx, 0x100000 ;ax要加1MB
mov esi, edx
; 算16MB以上
xor eax, eax
mov ax ,bx
mov ecx, 0x10000 ;64kb = 0x10000
mul ecx ;32位乘法,默认的被乘数是eax,积为64位
;高 32 位存入 edx,低 32 位存入 eax
add esi, eax
mov edx, esi
jmp .mem_get_ok
;----- int 15h ah = 0x88 获取内存大小,只能获取 64MB 之内 -----
.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
mul cx
shl edx, 16
and eax, 0x0000FFFF
or edx, eax
add edx, 0x100000
.mem_get_ok:
mov [total_mem_bytes], edx
;------------------------ 准备进入保护模式 ------------------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1
in al, 0x92
or al, 0000_0010B
out 0x92, al
;加载GDT
lgdt [gdt_ptr]
;cr0 pe位置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'
jmp $
运行我们的程序 可以看到我们的 total_mem_bytes
由于我们的机器装的是32MB内存,我们经过换算是0x2000000 = 32MB
启用内存分页机制,畅游虚拟空间
进入保护模式后,地址空间为4GB,这个时候要需要了解虚拟地址空间的实现原理
内存为什么要分页
目前我们是直接在内存分段机制下进行工作的,只是一个loader再跑,虽然不能出现什么问题,但是如果我们的物理内存不够,比如系统中的应用程序过多,或者内存碎片过多没有办法容纳新的进程,或者曾经被换出硬盘中的内存需要再次装入内存,可是这时候内存中找不到合适大小的内存区域,这些问题都是要解决的
用图来说明问题:
第一步中,系统中有3个进程在运行,A,B,C分别占用了10MB,20MB,30MB,还有15MB属于还宽裕的状况,这时候我们的B结束后,我们的D进程需要20M+3KB,由于我们没有分页的功能,所以我们现在“段基址+段内偏移”产生的线性地址就是物理地址,并且物理地址是连续的,线性地址也是连续的,虽然剩下35MB可以使用,但是现在连续的内存块只有20MB和一个15MB,哪块都不够进程D用的
解决方案:
- 等C运行完腾出内存,这样连续可用的内存就可以运行进程D了
- 将进程A的段A3或者进程C的段C1换出道硬盘上,加上腾出来的20MB就可以容纳进程D了
第一个肯定不行,为了电脑使用的流畅性,如果你发现你的微信打开发现一直不动弹了,大家会以为电脑死机了,所以不好直接pass
第二种就是将老进程不常用的段换出到硬盘,腾出空间给新的进程使用,等老进程再次需要的时候,再从硬盘上将该段载入内存
内存管理的原理:内存段是怎样被换出的?
CPU 在引用一个段时,都要先查看段描述符,很多时候,段描述符存在于描述符表中(GDT 或 LDT)但是但与此对应的段可以不在内存中,我们利用提供给软件使用的这种策略,实现段式内存管理,如果描述符中的P位为1,那么表示段在内存中存在,访问了这个段后,CPU将段描述符中的A位置1,表示最近刚访问过该段,相反来说,P位为0,表示内存中不存在该段,这时候CPU会抛出NP(段不存在异常)从而执行处理程序,该程序的工作是将相应的 段从外存(比如硬盘)中载入到内存,并将段描述符的 P 位置 1,中断处理函数结束后返回,CPU 重复执行这个 检查,继续查看该段描述符的 P 位,此时已经为 1 了,在检查通过后,将段描述符的 A 位置 1
以上是 CPU 加载内存段的过程,内存段是何时移出到外存上的呢?
段描述符的 A 位由 CPU 置 1,但清 0 工作是由操作系统来完成的,因为操作系统每发现该位为 1 后就将该位清 0,这样一来,在一个周期内统计该位为 1 的次数就知道该段的使用频率了,从而可以找出使用频率最低的段,当物理内存不足时,可以将使用频率最低的段换出到硬盘,以腾出内存空间给新的进程。当段被换出到硬盘后, 操作系统将该段描述符的 P 位置 0。当下次这个进程上 CPU 运行后,如果访问了这个段,这样程序流就回到了 刚开始 CPU 检查出 P 位为 0、紧接着抛出异常、执行操作系统中断处理程序、换入内存段的循环
第二个问题我们虽然可以解决了一些问题,但是如果我们的物理内存特别小,但是我们进程段特别大,这样IO的操作会特别慢,怎么办,这时候为了要做到解除线性地址和物理地址一一对应的关系引入了页表这个概念
一级页表
正常来说,内存访问的核心机制依然是 段基址:段内偏移地址,这两个地址相加才是绝对地址,就是我们所认为的线性地址,这个线性地址在CPU下被认为是物理地址,直接拿来就能使用,也就是,这个线性地址直接送上地址总线,这个相加的过程的工作是CPU的段部件自动完成的,这个情况下的整个访问内存的过程
分页机制是建立在分段的机制之上的,如果按照默认的分段方式进行,那么经过段部件处理后的线性地址,CPU认为是物理地址,如果打开了分页机制,段部件输出的线性地址就不再等于物理地址了,叫做虚拟地址,这个地址是逻辑上,是假的,虚拟地址对应的物理地址需要在页表中查找,这个查找是由页部件自动完成的
分页的中心思想是,通过映射,可以使连续的线性地址与任意物理地址内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续
经过段的部件输出的线性地址的名字叫做虚拟地址
上图所表示的代码段和数据段在逻辑上被拆分成以页为单位的小内存块,这个时候的虚拟地址,不能存放任何数据,操作系统这个时候开始为这些虚拟内存页分配真实的物理内存页,它查找物理内存中可用的页,然后页表中登记这些物理页地址,这样就完成了虚拟页到物理页的映射
对于一一映射的问题:
在内存地址中,最简单就是逐字节映射,就是一个线性地址对应一个物理地址,比如线性地址 为 0x0,其对应的物理地址可以是 0x0、0x10 或其他你喜欢的数字,若线性地址为 0x1,对应的物理地址 为 0x1、0x11 或其他你喜欢的数字。我们需要找个地方来存储这种映射关系,这个地方就是页表,那么这个时候页表就是N行1列的表格,页表中的每一行称为页表项(PTE),大小是4字节,页表项的作用是存储内存物理地址,当访问一个线性地址的时候,实际上就是在访问页表项中所记录的物理内存地址
这个时候出现了一个问题,上面的方案说的是4GB空间划分为4GB个内存块,每个内存块大小是1字节,但是页表是存储在内存中的,为了表示这个32位地址,每个页表项必须要4字节,所以这么看的话,页表这个东西就要占用16GB,所以方案不合理
这个时候出现的方案就是,我们把32位这个地址拆分成2个部分,低地址部分是内存块大小,高地址部分就是内存块的数量,关系:内存块数*内存块大小 = 4GB
滑块指向第 12 位,内存块大小则为 2 的 12 次方,即 4KB,内存块数量则为 2 的 20 次方,1M,即 1048576 个,CPU 中采用的页大小恰恰就是 4KB
一级页表模型:
在一级页表模型中,右边第 11~0 位用来表示页的大小,也就是这个12位可以作为页内寻址,左边第31 - 12位用来表示页的数量,这20位用来索引页(0 - 0xfffff)表示第几个页
任何的地址最后都会落到某一个物理页中,那么32位地址空间在一级页表的模型中一共有1M个物理页,我们首先做的是定位到某个物理页,然后物理页内的偏移量就可以访问到任意1个字节的内存了,用20位二进制就可以表示全部的物理页,标准页都是4KB,12位二进制可以表达这4KB之内的任意地址
怎样用线性地址找到页表中对应的页表项:
之前的两个事情:
- 分页机制打开前,要将页表地址加载到寄存器cr3中,在打开分页机制前加载到寄存器 cr3 中的是页表的物理地址,页表中页表项的地址自然也是物理地址了
- 内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下 进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被 CPU 当作最终的物理地址直接送上地址总线,不会被分页机制再次转换(否则会递归转换下去)
如何通过线性地址找到其对应的页表项才是转换的关键,页表位于内存中,所以只要提供页表项的物理地址就能访问到页表项,页表本身属于线性表结构,相当于页表项数组,访问其中任意页表项成员,只要知道页表项的索引就可以了
转换原理:
一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节大小,高20位索引乘4后就是该页表项相对于页表物理地址的字节偏移量,用 cr3 寄存器中 的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线 性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址
总结一下页部件的工作:
用线性地址的高 20 位在页表中索引页表项,用线性地址的低 12 位与页表项 中的物理地址相加,所求的和便是最终线性地址对应的物理地址
mov ax,[0x1234]:
0x1234的高20位:0x1代表第一个页,查页表就是0x9000+0x234 = 0x9234 物理地址
总结:一级页表找到对应的地址的方法就是,首先要把地址分成低12位与高20位,高20位代表的是页表的索引,而低12位代表的是找到页表的索引后具体对应页中数据位置的索引,CR3要在分页之前加载页表的物理基地址,所以用[[cr3] + 索引1]+索引2
二级页表
一级页表到二级页表的转换,为什么有了一级页表,需要更新到二级页表
一级页表最多容纳1MB的表项,一个页表的大小就是4MB,每个进程都有自己的页表,进程一多,光是页表占用的空间就很大
所以要解决的是: 不要一次性地将全部页表项建好,需要时动态创建页表项
页表的标准页的尺寸都是 4KB,一级页表是将这 1M 个标准页放置到一张页表中,二级页表做的是,将1M个标准页,平均放到1K个页表中,每个页表中包含有 1K 个页表项,页表项是 4 字节大小,页表包含 1K 个页表项,故页表大小为 4KB,这恰恰是一个标准页的大小
这里也解决了我上次的疑问,在一级页表的时候,不知道页表的内存被放在哪里了,这次知道了由于页目录表和页表本身 都要占用内存,且为 4KB 大小,故它们也会由操作系统在物理内存中分配一物理页存放,图中最粗的线存放页目录表物理页,稍细一点的线指向的是用来存放页表的物理页,其他最细的线是页表项中分配的物理页,计算来说的话,页目录是4KB,页表是4KB,一共有1024个页表,1024*4kb = 4MB,所以占用的大小是4MB + 4KB 比一级页表多了4KB而已,物理内存每个页的大小是4KB,1024 * 1024 * 4KB = 4GB
在二级页表转换中,依然用 32 位虚拟地址的不同部分来定位物理页:
- 用虚拟地址的高10位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址,读取该页目录项,从中获取到页表的物理地址
- 用虚拟地址的中间 10位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址, 所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址
- 用虚拟地址的低 12 位作为页内偏移,所以虚拟地址的低 12 位加上第 2 步 中得到的物理页地址,所得的和便是最终转换的物理地址
页目录项和页表项
页目录和页表项内容不全都是物理地址,因为我们的页的大小都是0x1000对齐,所以地址的后12位都是0,就可以省出来来添加属性了
- P,Present,意为存在位,若为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中。操 作系统的页式虚拟内存管理便是通过 P 位和相应的 pagefault 异常来实现的
- RW,Read/Write,意为读写位,若为 1 表示可读可写,若为 0 表示可读不可写
- US,User/Supervisor,意为普通用户/超级用户位,若为 1 时,表示处于 User 级,任意级别(0、1、2、 3)特权的程序都可以访问该页,若为 0,表示处于 Supervisor 级,特权级别为 3 的程序不允许访问该页, 该页只允许特权级别为 0、1、2 的程序可以访问
- PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为 1 表示此项采用通写方式, 表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式, 本位用来间接决定是否用此方式改善该页的访问效率。这里咱们直接置为 0 就可以
- PCD,Page-level Cache Disable,意为页级高速缓存禁止位。若为1表示该页启用高速缓存,为0表 示禁止将该页缓存。这里咱们将其置为 0
- A,Accessed,意为访问位。若为 1 表示该页被 CPU 访问过啦,所以该位是由 CPU 设置的,页目录项和页表 项中的 A 位也可以用来记录某一内存页的使用频率(操作系统定期将该位清 0,统计一段时间内变成 1 的次 数),从而当内存不足时,可以将使用频率较低的页面换出到外存(如硬盘),同时将页目录项或页表项的 P 位置 0,下次访问该页引起 pagefault 异常时,中断处理程序将硬盘上的页再次换入,同时将 P 位置 1
- D,Dirty,意为脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1。此项仅针对页表项有效,并不会修改页目录项中的 D 位
- PAT,Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性。比较复杂,将 此位置 0 即可
- G,Global,意为全局位,为了提高获取物理地址的速度,将虚拟地址与物理地址转换结果存储在 TLB,G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页。若为全局页,该页将在高速 缓存 TLB 中一直保存,给出虚拟地址直接就出物理地址啦,无需那三步骤转换。由于 TLB 容量比较小(一般 速度较快的存储设备容量都比较小),所以这里面就存放使用频率较高的页面
- AVL,意为 Available 位,表示可用
启动分页机制做的工作
- 准备好页目录表及页表
- 将页表地址写入控制寄存器 cr3
- 寄存器 cr0 的 PG 位置 1
控制寄存器 cr3 用于存储页表物理地址,所以 cr3 寄 存器又称为页目录基址寄存器(Page Directory BaseRegister,PDBR)
cr3 寄存器的第 31~12 位中写入物理地 址的高 20 位,除第 3 位的 PWT 位和第 4 位的 PCD 位外,其余位都没用
cr0 PG 位为 1 后便进入了内存分页运行机制,段部件输出的线性地址成为虚拟地址,在将 PG 位置 1 之前,系统都是在内存分段机制下工作,段部件输出的线性地址便直接是物理地址,所以在第 2 步中,cr3 寄存器中的页表地址是真实的物理地址
规划页表之操作系统与用户进程的关系
为了计算机安全,用户进程必须运行在低特权级,当用户进程需要访问硬件相关的资源时,需要向操作系统申请,由操作系统去做,之后将结果返回给用户进程。进程可以有无限 多个,而操作系统只有一个,所以,操作系统必须“共享”给所有用户进程
用户的代码加上所需要的操作系 统中的部分代码才算完整的程序
所以设计的页表也要满 足这个基本要求:共享,只要保证所有用户进程 虚拟地址空间 3GB~4GB 对应的页表项中所记录的物理页地址是相同的就行,虚拟地址空间的 0~3GB 是用户进程,3GB~4GB 是操作系统
启用分页机制
分页机制得有页目录表,页目录表中的是页目录项,其中记录的是页表的物理地址及相关属性,所以还得有页表,我们实际的页目录表及页表也将按照此空间位置部署,地址的最下面是页目录表,往上依次是页表
PAGE_DIR_TABLE_POS equ 0x100000 ;物理地址
;--------------- 页表属性 -------------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_U equ 000b
PG_US_S equ 100b
设置了PAGE_DIR_TABLE_POS的值是0x1000000,这个位置是低端1MB上面的第一个字节
;-------- 创建页目录和页表 --------
setup_page:
;把页目录所占空间清0
mov ecx, 4096
xor esi, esi
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
;开始创建页目录项(Page Directory Entry)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;第一个页表的位置(仅次于页目录表,页目录表大小4KB)
mov ebx ,eax ;0x00101 000
;下面将页目录项0和OxcOO都存为第一个页表的地址 ,每个页表表示4MB内存
;这样Oxc03fffff(3G-3G04M)以下的地址和Ox003fffff(0-4M)以下的地址都 指向相同的页表
;这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;用户特权级,可读可写,存在内存
mov [PAGE_DIR_TABLE_POS + 0x0] , eax ;第一个目录项,0x00101 007
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;第0xc00高10位0x300=768个页表占用的目录项,0xc00以上属于kernel空间
;这里是把第768个目录页和第1个目录页指向同一个页表的物理地址:0x101000
;系统实际位于000~0x1000内存地址中,将系统虚拟地址0xc00000000映射到这低1M的空间内,只需要让0xc0000000的地址指向和低1M相同的页表即可
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 0xffc], eax ;使最后一个目录项指向页目录表自己的位置
;创建页表项(Page Table Entry)
mov ecx, 256 ;1M低端内存/每页大小4K = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ;地址为0x0,属性为7,111b
;这个页表项提供map地址的范围是0x0~0x100000,也就是低端1M
.create_pte:
mov [ebx+esi*4], edx
add edx, 0x1000
inc esi
loop .create_pte ;低端1M内存中,物理地址=虚拟地址,这里创建了1M空间的页表项
;创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;第二个页表
or eax, PG_US_U | PG_RW_W | PG_P ;111b
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ;768~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
大概描述一下上面代码的几个点,首先我们创建页目录项,页目录表的大小是4KB,所以我们需要逐比特清空,第一个页表的位置位于我们的页目录表的上面的一字节开始,所以我们的第一个页表的位置就是0x101000,因为所有的页表都是0x1000对齐的,所以,后12byte用来作为控制位,我们把0x101007 分别写入第一个页目录项,和第768个页目录项
为什么是0和768:
因为我们在加载内核之前,程序中运行的一直都是 loader,它本身的代码都是在 1MB 之内,必须保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致,因为在1MB以内,1MB = 0x100000,第 0 个页目录项代表的页表,其表示的空间是 0~0x3fffff,包括了 1MB(0~0xfffff),所以用了第 0 项来 保证 loader 在分页机制下依然运行正确
那么768是因为,我们将来会把操作系统内核放在低端 1M 物理内存空间,但操作系统的虚拟地址是 0xc0000000 以上,该虚拟地址对应的页目录项是第 768 个,0xc0000000 的高 10 位是 0x300,即十进制的 768,这样虚拟地址 0xc0000000~0xc03fffff 之间的内存都指向的是低端 4MB 之内的物理地址,这自然包括操作系 统所占的低端 1MB 物理内存,从而实现了操作系统高 3GB 以上的虚拟地址对应到了低端 1MB,也就是 如前所说我们内核所占的就是低端 1MB
我们将eax - 0x1000 = 0x100007,将其加入到页目录表中最后一个页目录项中,目的是为了将来能够动态操作页表
接下来就是创建页表项,1M低端内存,需要一共是4K大小页256个,创建页表我们从0地址开始依次加0x1000,属性为0x7即可
继续创建内核其他页表的PDE,第二个页表的所在位置就是在0x102000,我们需要创建的是,768-1022的所有的目录项,依次加0x1000即可
;----启用 分页机制----
;创建页目录和页表并初始化页内存位图
call setup_page
;gdt需要放在内核里
;将描述符表地址&偏移量写入内存gdt_ptr,一会用新的地址加载
sgdt [gdt_ptr] ;取出GDT地址和偏移信息,存放在gdt_ptr这个内存位置上
;视频段需要放在内核里与用户进程进行共享
;将gdt描述符中视频段的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,先选中GDT
or dword [ebx + 0x18 + 4], 0xc0000000 ;一个描述符8字节,0x18处是第3个段描述符也就是视频段,修改段基址最高位为C,+4进入高4字节,用or修改即可
;将gdt的基址加上 0xc0000000 成为内核所在的地址
add dword [gdt_ptr + 2 ], 0xc0000000
add esp, 0xc0000000 ;将栈指针同样map到内核地址
;页目录赋值给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 eax, SELECTOR_VIDEO
mov gs, eax
mov byte [gs :320], 'V'
需要把显存段加载到高1GB虚拟地址空间里,因为sgdt [gdt_ptr],这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,所以偏移量+4就是显存的base,我们测试一下
nasm -I include/ -o mbr5_1.bin mbr5_1.S
nasm -I include/ -o loader5_2.bin loader5_2.S
dd if=/Users/l0x1c/Code/os/boot/chapter5_2/mbr5_1.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 conv=notrunc
dd if=/Users/l0x1c/Code/os/boot/chapter5_2/loader5_2.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=4 seek=2 conv=notrunc
可以看到gdt的base和显存段已经是虚拟地址了
虚拟地址的gdt base相对应找PDE PTE的过程
用虚拟地址访问页表
页表是一种动态的数据结构,比如申请一块内存时,需要往里面添加页表项或者页目录项,比如在释放一块内存时,页表中相应的页表项或页目录项都要清零,二级页表灵活的地方,根据需要动态增减
在分页机制下,如何用虚拟地址访问到页表自身:
让虚拟地址与物理地址乱序映射,之前最后一个页目录项中 填入了页目录表的物理地址,mov [PAGE_DIR_TABLE_POS + 0xffc], eax
其中的前两个知道是为什么存在,因为我们自己做的页表的映射
后三个从第一个说起:
0xffc00000~0xffc00fff -> 0x000000101000~0x000000101fff
因为0x3ff这里代表了最后一个页表目录项,这里的值指向的页表的地址,这里检索到后了,他把0x100000当作了页表地址,里面的值为101000加上页表中的偏移0 就是0x101000
0xfff00000~0xfff00fff -> 0x000000101000~0x000000101fff
因为0x300,也是第一个页表中的数值,0x101000
0xfffff000~0xffffffff -> 0x000000100000~0x000000100fff
0xfffff000 的高 10 位是 0x3ff,中间 10 位依然是 0x3ff,代表了在页目录表找到了自己,然后继续索引到了自己,然后加上自己0的偏移就是0x100000
用虚拟地址获取页表中各数据类型的方法:
- 获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即 0xfffff000,这也是页目录表中第 0 个页目录项自身的物理地址
- 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为 0xfffffxxx,其中 xxx 是页目录项的索引乘以 4 的积
- 访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址,中间 10 位 为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4,低 12 位为页表内的偏移地址,用来定位页表项,它必须是已经乘以 4 后的值 公式为
(0x3ff<<22+中间 10 位<<12+低 12 位)
快表 TLB(Translation Lookaside Buffer)简介
处理器准备了一个高速缓存,可以匹配高速的处理器速率和低速的内 存访问速度,它专门用来存放虚拟地址页框与物理地址页框的映射关系,这个调整缓存就是 TL
处理器提供了指令invlpg(invalidate page),它用于在TLB中刷新某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地,指令 invlpg 的操作数也是虚拟地址,其指令格式为 invlpg m
比如要 更新虚拟地址0x1234对应的条目,指令为invlpg [0x1234]
加载内核
用c语言写内核
生成 C 语言程序的过程:先将源程序编译成目标文件(由 c 代码变成汇编代码后,再由汇编 代码生成二进制的目标文件),再将目标文件链接成二进制可执行文件
第一个c语言代码:
int main(void)
{
while(1);
return 0;
}
这个第一个c语言代码,只用了c语言的语法结构,这里没有包含系统库,也没有系统调用,这个文件只做了将cpu停在这里
我们用gcc把上面的文件编译成.o文件
gcc -c -0 ./main.o ./main.c
//-c 的作用是编译、汇编到目标代码,不进行链接,也就是直接生成目标文件
//-o 是命名
当前的main.o文件是一个未重定位的文件,由于不知道可执 行文件由几个目标文件组成,所以一律在链接阶段对符号重新定位(编排地址),重定位的操作都在链接的状态进行处理
我们的main.c中只有一个main的符号,nm列出了符号的信息,main函数地址由于没有指定,所以其值为0x0
Linux 下用于链接的程序是 ld,链接有一个好处,可以指定最终生成的可执行文件的起始虚拟地址,它是用-Ttext 参数来指定的
ld ./main.o -Ttext 0xc0001500 -e main -o ./main.bin
-Ttext 指定起始虚拟地址为 0xc0001500,这个地址是设定好的,为什么请看将内核载入内存中的设定
-e是entry的含义,之前学过知道我们真正的函数的入口点是_start,我们在不加入e参数指定的时候,我们会报错,说找不到start的
所以我们改变一下上面的c代码
int _start(void)
{
while(1);
return 0;
}
发现没有报错了,并且file中查看文件类型已经变成了可执行的文件类型
二进制程序的运行方法
这里大概介绍了文件格式的问题,就是pe或者elf那种eop入口点的问题,执行
elf 格式的二进制文件
elf目标文件归类:
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[16]; //存储字符信息
Elf32_Half e_type; //目标文件类型
Elf32_Half e_machine; //elf目标体系结构
Elf32_Word e_version; //版本信息
Elf32_Addr e_entry; //操作系统运行程序时,将控制权转交到的虚拟地址
Elf32_Off e_phoff; //程序头表的文件偏移地址
Elf32_Off e_shoff; //节头表的文件偏移地址
Elf32_Word e_flags; //处理器相关标识
Elf32_Half e_ehsize; //elf header 字节大小
Elf32_Half e_phentsize; //程序头表每个条目的字节大小,条目的数据结构是Elf32_Phdr
Elf32_Half e_phnum; //程序头表中条目数量(段数量)
Elf32_Half e_shentsize; //节头表每个条目的字节大小
Elf32_Half e_shnum; //节头表中条目的数量(节数量)
Elf32_Half e_shstrndx; //指明string name table在节头表中的索引index
}Elf32_Ehdr;
e_ident[16]是 16 字节大小的数组,用来表示 elf 字符等信息
e_type 占用 2 字节,是用来指定 elf 目标文件的类型
e_machine 占用 2 字节,用来描述 elf 目标文件的体系结构类型,也就是说该文件要在哪种硬件平台
Elf32_Phdr 类似 GDT 中段描述符的作用,用来描述位于磁盘上的程序的一个段
typedef struct{
Elf32_Word p_type; //此段的作用类型
Elf32_Off p_offset; //本段在文件内的起始偏移字节
Elf32_Addr p_vaddr; //本段在内存中的起始虚拟地址
Elf32_Addr p_paddr; //仅用于与物理地址相关的系统中
Elf32_Word p_filesz;//本段在文件中的大小
Elf32_Word p_memsz; //本段在内存中的大小
Elf32_Word p_flage; //本段相关的标识
Elf32_Word p_align; //本段在文件和内存中的对齐方式,如果是1或0,则不对齐,否则应该是2的整数次幂
} Elf32_phdr;
p_type 占用 4 字节,用来指明程序中该段的类型
p_offset 占用 4 字节,用来指明本段在文件内的起始偏移字节
p_vaddr 占用 4 字节,用来指明本段在内存中的起始虚拟地址
p_paddr 占用 4 字节,仅用于与物理地址相关的系统中,因为 System V 忽略用户程序中所有的物理地址,所以此项暂且保留,未设定、
p_filesz 占用 4 字节,用来指明本段在文件中的大小
p_memsz 占用 4 字节,用来指明本段在内存中的大小
p_flags 占用 4 字节,用来指明与本段相关的标志
将内核载入内存
dd if= kernel.bin of=/your_path/hd60M.img bs=512 count=200 seek=9 conv=notrunc
count值设置比较大没有关系的,dd命令会自己判断写入的数据量,如果参数 if 指定的文件体积小于 count*bs,只按实际文件大小写入
loader.S 需要修改两个地方:
- 加载内核:需要把内核文件加载到内存缓冲区
- 初始化内核:需要在分页后,将加载进来的 elf 内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此 loader 的工作结束
我在ubuntu上编译这个文件的命令进行了改变,因为默认编译出来的是x64的
gcc -m32 -c -o ./main.o ./main.c
ld ./main.o -N -Ttext=0xc0001500 -e main -m elf_i386 -o ./kernel.bin
nasm -I include/ -o mbr5_3.bin mbr5_3.S
nasm -I include/ -o loader5_3.bin loader5_3.S
dd if=/Users/l0x1c/Code/os/boot/chapter5_3/mbr5_3.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=1 conv=notrunc
dd if=/Users/l0x1c/Code/os/boot/chapter5_3/loader5_3.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=4 seek=2 conv=notrunc
dd if=/Users/l0x1c/Code/os/boot/chapter5_3/c/kernel/kernel.bin of=/usr/local/Cellar/bochs/2.7/hd60M.img bs=512 count=200 seek=9 conv=notrunc
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_START_SECTOR equ 0x9
;-------------kernel
KERNEL_ENTRY_POINT equ 0xC0001500
PT_NULL equ 0
这里代表了我们的kernel_bin放入的是0x70000位置,扇区是9,我们把kernel的虚拟地址排定在0xc0001500其实就是在物理内存0x1500,换算一下虚拟地址对物理地址的转换,就是
;----加载kernel-----
mov eax, KERNEL_START_SECTOR ;kernel.bin 所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ;磁盘读出后,写到ebx的指定的地址中
mov ecx, 200 ;读入的扇区数
call rd_disk_m_32 ;从硬盘读取文件到内存,上面eax,ebx,ecx是参数
在没有分页之前我们把kernel载入指定的内存中,我们的m_32只需要我们把对应的register修改成32位的即可
;-------------- 功能读取硬盘n个扇区 -----------
rd_disk_m_32:
;eax = LBA扇区号
;ebx = 将数据写入的内存地址 因为现在的ebx的地址是已经是32位了
;ecx = 读入的扇区数
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,8
shr eax,cl ;逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来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 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5---0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512字节,一次读取2字节,需要读取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
在分页之后,因为我们在链接的时候已经给了相应的虚拟地址,所以我们直接在分页之后执行我们的代码( jmp KERNEL_ENTRY_POINT)
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新 gdt
enter_kernel:
call kernel_init
mov esp, 0xc009f000 ;给栈选个高地址且不影响内存其他位置的地方
jmp KERNEL_ENTRY_POINT
;-------- 创建页目录和页表 --------
把相应的segment拷贝到虚拟地址中,就是转换后的地址中
;---------- 将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] ;e_phentsize,表示 program header 大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ;表示第 1 个 program header 在文件中的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ;获取程序头表第一个程序头的地址(基地址 + 偏移量)
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ;偏移文件 44 字节处是 e_phnum,表示程序头的数量
.each_segment:
cmp byte [ebx + 0], PT_NULL ;若 p_type 等于 PT_NULL,说明此 program header 未使用
je .PTNULL
;为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
;参数 size:待复制的大小
push dword [ebx + 16] ;偏移程序头 16 字节处是 p_filesz 本段在文件内的大小
;参数 src:源地址
mov eax, [ebx + 4] ;偏移程序头 4 字节处是 p_offset 本段在文件内的偏移大小
add eax, KERNEL_BIN_BASE_ADDR ;加上基地址 = 物理地址
push eax
;参数 dst:目的地址
push dword [ebx + 8] ;偏移程序头 8 字节处是 p_vaddr 本段在内存中的虚拟地址
call mem_cpy
add esp, 12
.PTNULL:
add ebx, edx ;在此ebx指向下一个program header
loop .each_segment
ret
;----逐字节拷贝 mem_cpy(dst, src, size)---
mem_cpy:
cld ;控制进行字符串操作时esi和edi的递增方式,cld增大,sld减小
push ebp
mov ebp, esp
push ecx ;rep指令用到了ecx,外层指令也用到了ecx,所以备份
mov edi, [ebp + 8] ;dst
mov esi, [ebp + 12] ;src
mov ecx, [ebp + 16] ;size
rep movsb ;逐字节拷贝
;恢复环境
pop ecx
pop ebp
ret
效果:
因为源代码就是循环所以就是jmp当前的地址