真象还原 --环境/准备 study(1)

真象还原 --环境/准备 study(1)

H_Haozi Lv3

A 前情提要

在学这个之前,在操作系统方面我可能只了解过 IMX6U 的嵌入式linux板子, 以及 RTOS 相关的概念,硬件方面主要是 STM32 ,所以记录的东西可能会相对从基础开始……
ps:如果参考本系列文章来实操,需要结合《操作系统真象还原》一起观看,否则会缺失很多细节

B 前置知识

B.1 汇编

首先是去重新复习了一下汇编指令相关的内容,因为在书中讲解编译器,代码段等的时候会有汇编的例子,所以这里简单复习一下基础知识

  • 指令 = 操作码 + 操作数

下面主要列出一些常用的内容:

mov S,D :传送,将S的值传递给D

也可以分为 movb movw movl movq 用来传送不同字节大小的内容,分别为1个字节,2个字节,4个字节,8个字节。当然也有条件传递

pushl S 把S的值压入栈顶(4个字节)
popl D 把栈顶的值弹出给D

这里主要注意操作栈的指令会去修改 esp(栈指针的地址)

计算相关(修改寄存器,设置条件码)
INC D D = D+1
ADD S,D D = S+D
SUB S,D D = D-S
OR S,D D = D|S
AND S,D D = D&S

这里面比如,ADD指令如果相加溢出之后,也会修改标志位寄存器

cmp S2,S1 基于S1-S2设置条件码
test S2,S1 基于S1&S2设置条件码

这里根据计算结果来设置条件码

jmp label 无条件跳转

当然还有别的类型的跳转,这里不详细列出

call label 调用函数label,将label放入%eip中
ret 函数返回,pop %eip


举个例子,通过汇编来实现一个简单的判断x,y差值的绝对值

1
2
3
4
5
6
7
8
int absdiff(int x,int y)
{
if( x<y> )
{
return y-x;
}
else return x-y;
}

下面是通过汇编来实现–注意这里的x86asm只是为了代码高亮而使用的标识符(后面同理)

1
2
3
4
5
6
7
8
9
10
11
12
# 假设 x 在 8(%ebp), y 在 12(%ebp)
movl 8(%ebp), %edx # edx = x
movl 12(%ebp), %eax # eax = y
cmpl %eax, %edx # compare x and y
jge .Lge
subl %edx, %eax # y = y - x (覆盖 eaxeax <- y - x)
jmp .Ldone
.Lge:
subl %eax, %edx # x = x - y (覆盖 edxedx <- x - y)
movl %edx, %eax # 把结果放到 eax 以统一返回位置
.Ldone:
# 结果在 %eax:为较大者减去较小者(>=0,除非溢出)

CPU对外设的操作通过专门的端口读写指令完成:

in al,21H : 从21H端口调取一字节到al
out 21H,al: 将al的值写入21H端口

shr eax, cl: 逻辑右移,将eax右移动cl位

目前就先了解这些,后面的如果实际使用再进行补充学习


C 书中的操作系统知识—–(一些你可能正感到迷惑的问题)

这里也是参考书籍选择性的做一些笔记,了解这些也是为后面实操做准备


C.1 物理地址、逻辑地址、有效地址、线性地址、虚拟地址的区别

  • 物理地址 是物理内存真正的地址,具有唯一性,无论CPU通过虚拟地址,线性地址等访问,最终都是通过物理地址访问,它是访问内存的终点站

在实模式下,段基址+段内偏移地址,再经过段部件的处理,然后就会直接输出物理地址,CPU可以直接访问

在保护模式下,段基址+段内偏移地址(也称为线性地址),需要判断是否打开地址分页的功能,如果没有,那么就可以当做物理地址。如果有,那么需要通过分页机制来查找对应的物理地址


C.2 BIOS 中断、DOS 中断、Linux 中断的区别

BIOS属于固件级的服务例程,DOS属于操作系统级的服务,Linux终端属于操作系统内核的功能,当然这里是简单了解一下

BIOS 和 DOS 都是存在于实模式下的程序,由它们建立的中断调用都是建立在中断向量表(Interrupt Vector Table,IVT)中的,它们都是通过软中断指令 int 中断号来调用的

BIOS是固化在计算机主板上ROM芯片中的一组基础程序。BIOS中断是这组程序提供的服务例程,是计算机加电后最早能使用的软件功能

DOS是一个16位的单用户单任务操作系统。DOS中断是DOS操作系统提供给应用程序的API(应用程序编程接口)。​DOS本身是构建在BIOS之上的,它的很多功能是通过调用BIOS中断来实现的。


C.3 实模式,保护模式,长模式分别是什么

特性 实模式 保护模式 长模式
出现时间 8086/8088 (1978) 80286 (1982) AMD Opteron / Athlon 64 (2003)
地址总线 20位 32位(或更多) 64位
寻址空间 1 MB (2^20) 4 GB (2^32) 256 TB (理论 2^64,实际 48/57位实现)
通用寄存器 16位 (AX, BX, …) 32位 (EAX, EBX, …) 64位 (RAX, RBX, …)
核心特性 无内存保护,直接物理地址访问 内存保护、虚拟内存、多任务硬件支持 64位扩展、寄存器数量翻倍、更优的指令集
寻址方式 段地址 × 16 + 偏移地址(物理地址) 选择子 + 偏移地址(通过描述符表转换为线性地址) 基本平坦内存模型(段基址通常为0)
特权级 仅有 Ring 0(所有代码权限相同) Ring 0, 1, 2, 3(通常只用 Ring 0-内核 和 Ring 3-用户) 仅保留 Ring 0 和 Ring 3
主要应用 早期DOS系统、BIOS 所有现代32位操作系统(Windows XP/7, Linux 32位) 所有现代64位操作系统(Windows 10/11, macOS, Linux 64位)

ps:后面也有专门讲保护模式的部分


ps: 如果后面有相关知识补充也是放到这个模块


D 搭建环境

“C 语言虽然不是为设计大型软件而生的,但其却被用来开发大型软件,现代操作系统基本上是用 C 语言再结合汇编语言开发的,所以 C 语言编译器,我们选择的是 gcc。而汇编语言编译器,我们选择的是 nasm。为什么选择这两个,首先因为它们都是开源软件,其次其强大的功能不亚于同类的商业软件。”

对于这个搭建环境的过程,如果之前了解过linux的来说那都是很容易的~

D.1 需要的工具

编译器: GCC NASM
软件环境: 虚拟机 Centos6.3/ubuntu16.04 Bochs2.6.2
工具: 虚拟机与主机之间传输文件,我这里选择使用xftp软件,远程连接使用的vscode的插件/xshell,写代码可以选远程连接写或者centos/ubuntu也下载一个vscode

虚拟机我用的vmware,因为之前学习也是这个,其它都是根据书中的环境来的,以方便找出错误

下面就主要安装虚拟机,编译安装配置运行Bochs,然后就开始写代码吧


D.2 配置流程

  • 虚拟机配置 这里网上教程比较多,就不多说了,我最终是选择在ubuntu16环境下

  • 下载Bochs 这里和教程一样,通过下载源码,配置编译,bochs-2.6.2.tar.gz下载地址,然后通过xftp或者其它工具发送到虚拟机中

  • 解压,进入目录,然后configure、make、make install 三步曲

1
2
3
4
5
6
7
8
9
10
11
12
13

cd tool #进入bochs-2.6.2.tar.gz所在目录
tar -xvf bochs-2.6.2.tar.gz #解压
cd bochs-2.6.2 #进入解压后的目录

# 这里是配置安装路径 支持GDB 支持反汇编,启动io接口调试器,支持x86调试器,使用xwindows 使用x11接口等
# (要注意的是,教程是使用bochs自带的调试,如果要使用需要将--enable-gdb-stub修改成数--enable-debugger,且他们不能同时存在)
./configure --prefix=/home/mouse/OS_mouse/tool/bochs --enable-gdb-stub --enable-disasm --enable-iodebug --enable-x86-debugger --with-x --with-x11

#生成Makefile文件之后(如果有报错,可以检查报错,看是不是少下了什么依赖)

make install #完成安装

ps: 注意我这里选择的是GDB调试,所以后面的代码也是与此相关,如果使用Bochs 自带的调试工具,可以参考书中的内容学习,其实都大差不差


D.3 遇到的问题

  • 在虚拟机安装Centos的时候,弹出窗口报错This hardware(or a combinationthereof)is not supported byRedHat.For more information onsupported hardware,please referto http://www.redhat.com/hardware

我尝试根据网上的说法,打开笔记本的BIOS界面,打开虚拟化的设置选项(其实我原本就是打开的),但是尝试后,还是会有当前界面,所以我选择F12先跳过这个界面,如果后面再遇到相关问题再记录

  • 在配置bochs的时候遇到报错configure: error: in /home/mouse/tool/bochs-2.6.2':configure: error: C++ preprocessor "/lib/cpp" fails sanity check,提示缺少g++的内容,然后准备yum下载,那这里顺便记录一下换镜像源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

#备份原本的内容
cp /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak

#下载新的配置文件
curl -o /etc/yum.repos.d/CentOS-Base.repo https://mirrors.aliyun.com/repo/Centos-vault-6.10.repo

#清理并生成新的缓存
yum clean all
yum makecache

#更新软件(可选)
sudo yum -y update

#安装c++
sudo yum install gcc-c++

当然,如果有其他报错或者警告,可以查看是缺少了,和上面一下安装对应的东西即可


ps:其实为了方便我学习其他linux相关,我最终还是选择使用熟悉的环境ubuntu16

  • 出现undefined reference to 'pthread_create' undefined reference to 'pthread_join'错误,可以参考书中最后给出的解决办法

这里就不多说了


E 开始操作–bochs环境 & MBR

从这里开始就是重点了(咳咳,其实前面也是)

下面主要记录我使用的指令,或者编辑的文件(ps:其中的路径,比如/home/mouse/OS_mouse/tool 需要更改成自己的路径)

E.1 配置bochs

前面通过配置,然后写一个简单的bochs 支持GDB调试的配置文件,方在bcohs安装路径下即可,比如我/tool/bochs/bochsrc.disk
(其中ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63需要通过创建虚拟硬盘的工具 bin/bximage 获得)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
###############################################
# Configuration file for Bochs
###############################################

# 第一步,首先设置 Bochs 在运行过程中能够使用的内存,本例为 32MB。
# 关键字为:megs
megs: 32

# 第二步,设置对应真实机器的 BIOS 和 VGA BIOS。
# 对应两个关键字为:romimage 和 vgaromimage
romimage: file=/home/mouse/OS_mouse/tool/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/home/mouse/OS_mouse/tool/bochs/share/bochs/VGABIOS-lgpl-latest

# 第三步,设置 Bochs 所使用的磁盘,软盘的关键字为 floppy。
# 若只有一个软盘,则使用 floppya 即可,若有多个,则为 floppya,floppyb…
# floppya: 1_44=a.img, status=inserted

# 第四步,选择启动盘符。
#boot: floppy #默认从软盘启动,将其注释
boot: disk #改为从硬盘启动。我们的任何代码都将直接写在硬盘上,所以不会再有读写软盘的操作。

# 第五步,设置日志文件的输出。
log: bochs.out

# 第六步,开启或关闭某些功能。
# 下面是关闭鼠标,并打开键盘。
mouse: enabled=0
keyboard_mapping: enabled=1, map=/home/mouse/OS_mouse/tool/bochs/share/bochs/keymaps/x11-pc-us.map

# 硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63
#

# 下面的是增加的 bochs 对 gdb 的支持,这样 gdb 便可以远程连接到此机器的 1234 端口调试了
gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0
################### 配置文件结束 #####################

前面说了其中硬盘设置的第二行是通过工具 bin/bximage 获得

1
2
# 创建一个硬盘,类型为flat 大小为60MB 静默模式
bin/bximage -hd -mode="flat" -size=60 -q hd60M.img

执行命令后,会在The following line should appear in your bochsrc:的后面得到一串内容,我们直接更新到bochsrc.disk中即可

E.2 体验BIOS

书中对bios(Base Input & Output System)基本输入输出系统的描述很详细,这里就不过多赘述,主要总结几点

  1. cpu通过cs:f000 ip:fff0的预设然后会直接进入 0x7C00地址执行
  2. 如果此扇区末尾的两个字节分别是魔数 0x550xaa,BIOS 便认为此扇区中确实存在可执行的程序(此程序便是久闻大名的主引导记录 MBR

下面是书中的一个例子:在屏幕上打印字符串1 MBR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S
; 主引导程序
;------------------------------------------------------------
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00

; 清屏利用 0x06 号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为 0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0x600
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

;;;;;;;;; 下面这三行代码获取光标位置 ;;;;;;;;;
;.get_cursor 获取当前光标位置,在光标位置处打印字符。
mov ah, 3 ; 输入: 3 号子功能是获取光标位置,需要存入 ah 寄存器
mov bh, 0 ; bh 寄存器存储的是待获取光标的页号

int 0x10 ; 输出: ch=光标开始行,cl=光标结束行
; dh=光标所在行号,dl=光标所在列号

;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;;

;;;;;;;;; 打印字符串 ;;;;;;;;;;;
;还是用 10h 中断,不过这次调用 13 号子功能打印字符串
mov ax, message
mov bp, ax ; es:bp 为串首地址,es 此时同 cs 一致,
; 开头时已经为 sreg 初始化

; 光标位置要用到 dx 寄存器中内容,cx 中的光标位置可忽略
mov cx, 5 ; cx 为串长度,不包括结束符 0 的字符个数
mov ax, 0x1301 ;子功能号 13 显示字符及属性,要存入 ah 寄存器,
; al 设置写字符方式 ah=01: 显示字符串,光标跟随移动
mov bx, 0x2 ; bh 存储要显示的页号,此处是第 0 页,
; bl 中是字符属性,属性黑底绿字(bl = 02h)
int 0x10 ; 执行 BIOS 0x10 号中断
;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;;

jmp $ ; 使程序悬停在此

message db "1 MBR" ;设置字符串
times 510-($-$$) db 0 ;通过计算当前地址和Section地址的差,也就是填充512字节中除去末尾两个字节以外没有使用的地址为0
db 0x55,0xaa ;添加魔数

然后就可以通过 nasm 来到文件路径下来编译这个代码,并且放到磁盘中

1
2
3
4
5
6
sudo apt-get install nasm   #如果没下载

nasm -o mbr.bin mbr.S #mbr.S 是刚刚的文件的名称,编译成功后会生成mbr.bin文件

# 意思是(if)读取mbr.bin文件 (of)移动到hd60M.img中 (bs)指定512MB (count)指定块大小 (conv)转换方式 建议使用notrunc 方式(不打断文件)
dd if=/home/mouse/OS_mouse/tool/bochs/mouse/mbr.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=1 conv=notrunc

然后就会输出记录了1+0 的读入 记录了1+0 的写出 512 bytes copied, 0.000408059 s, 1.3 MB/s

1
2
3
4
5
6
bin/bochs -f bochsrc.disk   //通过文件 bochsrc.disk 运行虚拟机,然后回车即可,这个时候虚拟机会弹出另一个窗口

# 然后会告诉你让你连接GDB调试 可以直接另开一个终端
gdb #打开gdb 后面输入的指令会有前缀(gdb)
target remote localhost:1234 #连接本地端口1234
continue #进入之后可以选择继续运行

如果前面步骤没问题,那么弹出的窗口上便会有字符串1 MBR显示

E.3 编写MBR

通过书中从硬件到软件的介绍,现在改写之前的文件,将BIOS的输出改成通过显存输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S
;主引导程序
;
;LOADER_BASE_ADDR equ 0xA000
;LOADER_START_SECTOR equ 0x2
;------------------------------------------------------------
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 号功能,上卷全部行,则可清屏
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为 0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; VGA 文本模式中,一行只能容纳 80 个字符,共 25 行
; 下标从 0 开始,所以 0x18=24,0x4f=79
int 10h ; int 10h

; 输出背景色绿色,前景色红色,并且跳动的字符串"1 MBR"

mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4 ; A 表示绿色背景闪烁,4 表示前景色为红色

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

同之前的流程,nasm编译,dd写入硬盘,然后执行,最终可以看到屏幕上有绿色背景闪烁的字符

E.4 GDB调试简介

这里直接举例子说功能吧

首先启动Bochs,然后gdb工具连接,但是不要输入continue

1
(gdb) x/i 0xffff0       # x表示查看内存 i表示以指令格式显示 0xffff0表示查看的地址

示例输出: 0xffff0: ljmp $0x3131,$0xf000e05b

其它命令也是同理,这里简单给个表格,在后面实际使用的时候再列出其它指令

命令 作用
break *0xffff0 0xffff0 设置断点
continue (c) 继续执行
stepi (si) 单步执行(进入函数调用)
nexti (ni) 单步执行(跳过函数调用)
x/xw 0xb00 以十六进制查看 0xb00处的 4 字节
x/dw 0xb00 以十进制查看 0xb00处的 4 字节
x/tw 0xb00 以二进制查看 0xb00处的 4 字节
x/s 0xb00 以字符串格式查看 0xb00处的数据
  • 实模式调试:若调试 16 位代码,需先设置架构:

E.5 硬盘接力

下面来让MBR从BIOS接手之后来使用硬盘,而使用硬盘即读取磁盘的函数,而我们的MBR受限于512字节,所以需要通过另一个程序来实现: loader(加载器)
最终MBR的使命就是: 将硬盘上的loader加载到内存,然后将接力棒交给它,同时后面的内核也要用到loader里的数据结构,所以我们选择将loader放在低处,这里和教程一样,选择加载地址为0x900

下面是再次更新(添加硬盘读取的代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S
;主引导程序
;
;LOADER_BASE_ADDR equ 0xA000
;LOADER_START_SECTOR equ 0x2 这里的内容添加在了boot.inc中,下面的%include引用
;------------------------------------------------------------

%include "boot.inc"

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 号功能,上卷全部行,则可清屏
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为 0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; VGA 文本模式中,一行只能容纳 80 个字符,共 25 行
; 下标从 0 开始,所以 0x18=24,0x4f=79
int 10h ; int 10h

; 输出背景色绿色,前景色红色,并且跳动的字符串"1 MBR"

mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4 ; A 表示绿色背景闪烁,4 表示前景色为红色

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,8 ; 待读入的扇区数,最开始可以是1,但后面会变大,所以一步到位,直接写8了嘿嘿
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR

; 功能:读取硬盘的n个分区
;----------------------------------------
; eax=LBA 扇区号
; bx=将数据写入的内存地址
; cx=读入的扇区数

;在16位下读取硬盘
rd_disk_m_16:
mov esi,eax ;备份eax
mov di,cx ;备份cx

;第 1 步: 设置读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数
mov eax,esi ;恢复eax

;第 2 步: 将LAB地址存入 0x1f3 ~ 0x1f6

;LBA 地址 7~0 位写入端口 0x1f3
mov dx,0x1f3
out dx,al

;LBA 地址 15~8 位写入端口 0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA 地址 23~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 ;空操作,类似于sleep一会
in al,dx
and al,0x88 ;第 4 位为 1 表示硬盘控制器已准备好数据传输
;第 7 位为 1 表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等

;第 5 步:从 0x1f0 端口读数据
mov ax, di
mov dx, 256
mul dx
mov cx, ax
; di 为要读取的扇区数,一个扇区有 512 字节,每次读入一个字 共需 di*512/2 次,所以 di*256
mov dx, 0x1f0

.go_on_read:
in ax,dx
mov [bx],ax
add bx,2
loop .go_on_read
ret

times 510-($-$$) db 0
db 0x55,0xaa

上面的代码成功实现了读取硬盘,同时将接力棒交给LOADER_BASE_ADDR地址的 loader 执行

这里引入了include的概念,所以还需要写一个boot.inc文件,我选择将它放在这个路径下的文件夹include里面

1
2
3
4
5
;/home/mouse/OS_mouse/tool/bochs/mouse/include/boot.inc
;---------- loader & kernel ----------

LOADER_BASE_ADDR equ 0x900 ;loader addr loader内存的位置
LOADER_START_SECTOR equ 0x2 ;loader lab addr loader逻辑扇区地址,即第二块扇区

同时,使用nasm编译的时候也需要更改指令,指定一下include的路径,最后用dd将bin文件写入硬盘

1
2
3
nasm -I include/ -o mbr.bin mbr.S

dd if=/home/mouse/OS_mouse/tool/bochs/mouse/mbr.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=1 conv=notrunc

这个时候如果执行,因为LOADER_BASE_ADDR地址下还什么都没有,所以CPU直接跳到0x900第二问位置,也没有什么结果,可以等下面的简易loader写完之后再尝试运行

所以下一步就是实现内核加载器(loader)

E.6 内核加载器(loader)

同书中所讲,这里实现的loader是只在实模式工作,loader 是要经过实模式到保护模式的过渡,并最终在保护模式下加载内核,等后面学完保护模式后,再来进阶的

这里loader里面写一个和之前MBR非常接近的代码,只是修改字符串为2 LOADER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
; 输出背景色绿色,前景色红色,并且跳动的字符串"2 LOADER"
mov byte [gs:0x00],'2'
mov byte [gs:0x01],0xA4 ; A 表示绿色背景闪烁,4 表示前景色为红色

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 $ ; 通过死循环使程序悬停在此

然后还是编译写入,但是这里的写入的扇区是2 seek=2,第0的扇区是MBR,第1个扇区是空的(原作者的爱好)

1
2
3
nasm -I include/ -o loader.bin loader.S 

dd if=/home/mouse/OS_mouse/tool/bochs/mouse/loader.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=1 seek=2 conv=notrunc

结果如下记录了0+1 的读入 记录了0+1 的写出 98 bytes copied, 0.000230323 s, 425 kB/s

然后就可以运行看看了,屏幕上会显示”2 loader”,当然这个loader并没有实际意义,只是来验证是否接力成功,最终的任务还是加载内核,从实模式到保护模式。

所以下面主要学习保护模式


F 保护模式

实模式下,访问的都是物理地址,而这样会造成很多问题,所以CPU工程师引入了保护模式,下面主要来简要记录保护模式的相关知识

  1. 实模式下操作系统和用户程序属于同一特权级,这哥俩平起平坐,没有区别对待。
  2. 用户程序所引用的地址都是指向真实的物理地址,也就是说逻辑地址等于物理地址
  3. 用户程序可以自由修改段基址,可以不亦乐乎地访问所有内存,没人拦得住。
  4. 访问超过 64KB 的内存区域时要切换段基址,转来转去容易晕乎。
  5. 一次只能运行一个程序,无法充分利用计算机资源。
  6. 共 20 条地址线,最大可用内存为 1MB,这即使在 20 年前也不够用。

CPU 有三种模式:实模式、虚拟 8086 模式、保护模式


F.1 全局描述符表 & 如何打开保护模式

全局描述符表(Global Descriptor Table,GDT)是保护模式下内存段的登记表,这是不同于实模式的显著特征之一。

  • 段描述符

段描述符(segment descriptor)是 8 字节(64 位)的结构,用来描述一个段的基址、长度和访问权限。CPU 通过选择子(selector)索引到 GDT/LDT 中的描述符,从而得到段基址和访问控制信息。

  • 全局描述符表 GDT、局部描述符表 LDT 及选择子

到了保护模式下后,由于已经是 32 位地址线和 32 位寄存器啦,任意一寄存器都能够提供 32 位地址,故不需要再将段基址乘以 16 后再与段内偏移地址相加啦,直接用选择子对应的“段描述符中的段基址”加上“段内偏移地址”就是要访问的内存地址。

  • 地址回绕

地址回绕是为了兼容 8086/8088 的实模式。如今我们是在保护模式下,我们需要突破第 20 条地址线(A20)去访问更大的内存空间。而这一切,只有关闭了地址回绕才能实现。而关闭地址回绕,就是上面所说的打开 A20Gate。
打开 A20Gate 的方式是极其简单的,将端口 0x92 的第 1 位置 1 就可以了

1
2
3
int al,0x92
or a1,0000_0010B
out 0x92,a1
  • 保护模式的开关,CR0 寄存器的 PE 位

PE 为 0 表示在实模式下运行,PE 为 1 表示在保护模式下运行,所以

1
2
3
mov eax,cr0
or eax,0x00000001
mov cr0,eax

F.2 打开保护模式

  • 首先要修改mbr.S文件中读入的扇区大小,因为loader.bin的大小会超过512字节,所以将 mov cx,1修改成 ``mov cx,4`
    注意在后期如果loader.bin的文件大小超过了mbr读取的扇区数,也要修改这个参数,同时在写入磁盘的时候也要注意大小,之前我们不是已经改成8了嘛
1
2
3
4
5
6
7
8
9
10
; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S

; 略...... ;

mov eax,LOADER_START_SECTOR ; 起始扇区 lba 地址
mov bx,LOADER_BASE_ADDR ; 写入的地址
mov cx,8 ; 待读入的扇区数
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

; 略...... ;

  • 下一个修改文件是boot.inc,因为loader.S文件中用到的配置都是定义在这个文件中的符号,修改如下

其中equ 是 nasm 提供的伪指令,意为 equal,即等于,用于给表达式起个意义更明确的符号名,其指令格式是符号名称 equ 表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
;/home/mouse/OS_mouse/tool/bochs/mouse/boot.inc
;--------------------- loader & kernel -------------------------

LOADER_BASE_ADDR equ 0x900 ;loader addr loader内存的位置
LOADER_START_SECTOR equ 0x2 ;loader lab addr loader逻辑扇区地址,即第二块扇区

;--------------------- gdt 描述符属性 -------------------------
DESC_G_4K equ 1_00000000000000000000000b ;第23位G段,设置粒度,段界限的单位值为4k
DESC_D_32 equ 1_0000000000000000000000b ;第22位D/B位,表示地址值用32位EIP寄存器,操作数与指令码32位
DESC_L equ 0_000000000000000000000b ;64 位代码标记,此处标记为 0 便可
DESC_AVL equ 0_00000000000000000000b ;CPU 不用此位,暂置为 0


DESC_LIMIT_CODE2 equ 1111_0000000000000000b
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b

DESC_P equ 1_000000000000000b ;判断是否存在于内存

DESC_DPL_0 equ 00_0000000000000b
DESC_DPL_1 equ 01_0000000000000b
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b

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 ;x=1,c=0,r=0,a=0 代码段是可执行的,非一致性,不可读,已访问位 a 清 0
DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写,已访问位 a 清 0

;代码段描述符高位4字节初始化 (0x00共8位 <<24 共32位初始化0)
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

;数据段描述符高位4字节初始化
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

;显存段描述符高位4字节初始化
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 + 0x0b
;这里注意末尾添加的是0X0b,而书中添加的0X00(初始化显存会失败)
;某些模拟器(如 Bochs)或硬件可能对显存地址有严格的要求,必须显式设置基地址的高位为 0x0B才能正确访问显存。

;--------------- 选择子属性 RPL 特权级比较是否允许访问 第2位TI 0表示GDT 1表示LDT 第3-15位索引值 -------------------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

代码中具体的说明书中有详细介绍


  • 最后一个文件是loader.S,如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

;1.构建gdt及其内部的描述符,拆分为上4字节,下4字节

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 ;此时 dpl 为 0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留 60 个描述符的空位times 是 nasm 提供的伪指令,用来重复执行 times 后面表达式(编译器执行)

SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

;以下是 gdt 的指针,前 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 = 13, AL = 01h
mov bx, 0x001f ; 页号为 0(BH = 0) 蓝底粉红字(BL = 1fh)
mov dx, 0x1800
int 0x10 ; 10h 号中断

;-------------------- 准备进入保护模式 -------------------------------
;1 打开 A20
;2 加载 gdt
;3 将 cr0 的 pe 位置 1

;----------------- 1. 打开 A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 2. 加载 GDT ----------------
lgdt [gdt_ptr]

;----------------- 3. cr0 第 0 位置 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 $

最后还是和之前一样编译运行,然后会在屏幕上看到第一行是原本在mbr.S文件中打印的字符,第二行的字符PZL是在保护模式打印的,最下面的字符串是在实模式用0x10中断打印的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#编译
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S

#写入
dd if=/home/mouse/OS_mouse/tool/bochs/mouse/mbr.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=1 seek=0 conv=notrunc

#这里写入的count=2 是因为我们的bin文件有634bytes,超过512*1,如果不修改写入的时候会被截短,那么屏幕上将不会有显示,所以后期也要注意文件大小的问题
dd if=/home/mouse/OS_mouse/tool/bochs/mouse/loader.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=2 seek=2 conv=notrunc

#开始运行
cd ..
bin/bochs -f bochsrc.disk
#-------------
gdb
(gdb) target remote localhost:1234
(gdb) continue

F.3 处理器微架构简介

F3.1 流水线

流水线是CPU 中的一个非常重要的技术,可以简单理解成CPU在并行执行任务,在”同一时间”用多级流水线将任务拆分,以高效率执行,举个简单例子:

指令执行单元 EU 是执行指令的唯一部件,一次只能执行一个指令,
单核 CPU 的情况下,只有一个指令处于执行中。CPU 中的各部分也是同时只能做一件事,但它们就像身体器官一样,也是在并行工作,相当于多个“人手”。CPU 的指令执行过程分为取指令、译码、执行三个步骤。
每个步骤都是独立执行的,CPU 可以一边执行指令,一边取指令,一边译码。CPU 中的时序不是秒,对 CPU 来说,秒就是天文数字。它的时序是时钟周期。


F3.2 乱序执行

乱序执行,是指在 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行,当然,得保证指令之间不具备相关性。

举个书中的例子:

1
2
mov eax,[0x1234]
add eax,ebx

这里的两条指令,因为ebx与eax相加需要依赖eax的值,所以必须等待第一步的mov操作结束,而相对内存访问比较慢,只能等着,顺序执行mov,然后是add
但是如果换一个代码:

1
2
mov eax,[0x1234]
add ecx,ebx

这个时候会发现add指令不依赖第一步的eax,所以cpu可以在执行mov指令的等待过程中去执行先执行第二步的add指令。由于第 2 步不依赖第 1 步,

总结一下,乱序执行的好处就是后面的操作可以放到前面来做,利于装载到流水线上提高效率


F3.3 缓存

缓存是 20 世纪最大的发明,其原理是用一些存取速度较快的存储设备作为数据缓冲区,避免频繁访问速度较慢的低速存储设备,归根结底的原因是低速存储设备是整个系统的瓶颈,缓存用来缓解“瓶颈设备”的压力。

CPU的缓存选择使用SRAM(即静态随机访问存储器),因为相对于CPU,DRAM(动态随机访问存储器)还是太慢了

什么时候能缓存呢?可以根据程序的局部性原理采取缓存策略。局部性原理是:程序 90%的时间都运行在程序中 10%的代码上。
局部性分为以下两个方面。
一方面是时间局部性:最近访问过的指令和数据,在将来一段时间内依然经常被访问。
另一方面是空间局部性:靠近当前访问内存空间的内存地址,在将来一段时间也会被访问。


F3.4 分支预测

CPU 中的指令是在流水线上执行。分支预测,是指当处理器遇到一个分支指令时,是该把分支左边的指令放到流水线上,还是把分支右边的指令放在流水线上呢?
如 C 语言程序中的 if、switch、for 等语言结构,编译器将它们编译成汇编代码后,在汇编一级来说,这些结构都是用跳转指令来实现的,所以,汇编语言中的无条件跳转指令很丰富,以至于称之为跳转指令“族”,多得足矣应对各种转移方式。

下面是一个例子,先创建一个while.c文件,内容如下:

1
2
3
4
5
6
7
8
9
///home/mouse/OS_mouse/tool/bochs/mouse/drafts/while.c
void main ()
{
int i = 0;
while (i < 10)
{
i++;
}
}

然后通过指令,用gcc生成对应的汇编文件,最后查看while.S

1
2
gcc -S -o while.S while.c   # -S表示编译成汇编语言,不进行汇编和链接
cat while.S # 查看该文件

这里不是源文件,我将部分注释添加到了文件里面,方便理解,同时,这个生成的汇编语言并不是我们熟悉的 Intel 语法(ps:其实我都不熟悉),而是 AT&T 语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
; -------- 声明代码段,导出main函数符号 --------
.file "while.c"
.text
.globl main
.type main, @function

; main函数入口地址
main:
.LFB0: ;标签,通常标识函数的开始

; 创建堆栈框架
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16

; 为int i = 0 在栈中分配空间
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $0, -4(%rbp)

;无条件跳转到标签.L2
jmp .L2
.L3: ;这里是while的循环体
addl $1, -4(%rbp)
.L2: ;这里是while循环的条件表达式
cmpl $9, -4(%rbp)
jle .L3 ;比较结果小于9就跳转到.L3
nop

;退出栈帧等
popq %rbp
.cfi_def_cfa 7, 8
ret ;退出main函数
.cfi_endproc
.LFE0: ;局部标签,函数结束标识
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits

如果分支预测错怎么办
也就是说,当前指令执行结果与预测的结果不同,这也没关系,只要将流水线清空就好了。因为处于执行阶段的是当前指令,即分支跳转指令。处于“译码”“取指”的是尚未执行的指令,即错误分支上的指令。只要错误分支上的指令还没到执行阶段就可以挽回,所以,直接清空流水线就是把流水线上错误分支上的指令清掉,再把正确分支上的指令加入到流水线,只是清空流水线代价比较大。


F3.5 清空流水线

之前的loader.S 文件中有个无跳转指令 jmp dword SELECTOR_CODE:p_mode_start 用来清空流水线

段描述符缓冲寄存器在 CPU 的实模式和保护模式中都同时使用,在不重新引用一个段时,段描述符缓冲寄存器中的内容是不会更新的,无论是在实模式,还是保护模式下,CPU 都以段描述符缓冲寄存器中的内容为主。实模式进入保护模式时,由于段描述符缓冲寄存器中的内容仅仅是实模式下的 20 位的段基址,很多属性位都是错误的值,这对保护模式来说必然会造成错误,所以需要马上更新段描述符缓冲寄存器,也就是要想办法往相应段寄存器中加载选择子。

所以,解决问题的关键就是既要改变代码段描述符缓冲寄存器的值,又要清空流水线。而 jmp 指令有清空流水线的神奇功效,所以使用远跳转指令清空流水线,更新段描述符缓冲寄存器


F.4 保护模式中的”保护”

保护模式中的保护二字主要体现在段描述符的属性字段中。每个字段都不是多余的。这些属性只是用来描述一块内存的性质,是用来给 CPU 做参考的,当有实际动作在这片内存上发生时,CPU 用这些属性来检查动作的合法性,从而起到了保护的作用

选择子的保护 当引用一个内存段时,实际上就是往段寄存器中加载个选择子,为了避免出现非法引用内存段的情况,在这时候,处理器会在以下几方面做出检查

  • 根据选择子的值验证段描述符是否超越界限
  • 检查段的类型-段描述符中还有个 type 字段
  • 检查盾是否存在-即检查P位是否为1

代码段和数据段的保护

对于代码段和数据段来说,CPU 每访问一个地址,都要确认该地址不能超过其所在内存段的范围

栈段的保护

CPU 对数据段的检查,其中一项就是看地址是否超越段界限


G 保护模式进阶

之前过多还是偏于理论基础,都是在为了后面的操作系统打基础,从现在开始,我们的代码将在保护模式下工作,除了开启虚拟内存外,我们还会接触到其他硬件,从这一刻起,现在才算开始了真正的操作系统学习之旅

G.1 获取物理内存容量

保护模式最“大”的特点就是寻址空间“大”,在进入保护模式之后,我们将接触到虚拟内存、内存管理等。但这些和内存有关的概念都建立在物理内存之上,无论理论概念说得多高大上,最终也要在物理内存上落实行动。为了在后期做好内存管理工作,现在的目的就是知道自己有多少内容

  • Linux下获取内存的方法:比如在 Linux 2.6 内核中,是用 detect_memory 函数来获取内存容量的。其函数在本质上是通过调用 BIOS 中断 0x15 实现

这里有三个子功能,子功能号要存放到寄存器 EAX 或 AX 中,如下。

EAX=0xE820:遍历主机上全部内存。
AX=0xE801: 分别检测低 15MB 和 16MB~4GB 的内存,最大支持 4GB。
AH=0x88:最多检测出 64MB 内存,实际内存超过此容量也按照 64MB 返回。


G1.1 获取方法简介

ARDS结构(地址范围描述符- Address Range Descriptor Structure),格式见下表

其中的type字段用来描述这段内存的类型,即是否可以被操作系统使用,还是保留起来不能使用

下面介绍调用BIOS中断0x15的0xe820需要的参数:

  • 调用前输入
  1. EAX:子功能号,这里输入0XE820
  2. EBX:ARDS后续值,因为没执行一次中断只返回一中类型的ARDS结构,所以要记录下一个待返回的内存ARDS,第一次调用必须写入0,每次返回后,BIOS会更新该值
  3. ES:DI:ARDS缓冲区,BIOS 将获取到的内存信息写入此寄存器指向的内存,每次都以 ARDS 格式返回
  4. ECX:ARDS结构的字节大小,用来指定BIOS写入的字节数(调用者和 BIOS 都同时支持的大小是 20 字节,将来也许会扩展此结构)
  5. EDX:固定签名标记0x534d4150
  • 调用后输出
  1. CF位:若CF为0则表示调用为出错,为1表示调用出错
  2. EAX:字符串0x534d4150(和之前的签名标记对应)
  3. ES:DI: ARDS缓冲区地址,通输入值是一样的,返回时这个地址已经被BIOS填充了内存信息
  4. ECX: BIOS 写入到 ES:DI 所指向的 ARDS 结构中的字节数,BIOS 最小写入 20 字节
  5. EBS:下一个 ARDS 的位置。每次中断返回后,BIOS 会更新此值,BIOS 通过此值可以找到下一个待返回的 ARDS 结构,咱们不需要改变 EBX 的值,下一次中断调用时还会用到它。在 CF 位为 0 的情况下,若返回后的 EBX 值为 0,表示这是最后一个 ARDS 结构

下面介绍调用BIOS中断0x15的0xe801需要的参数:

另一个获取内存容量的方法是 BIOS0x15 中断的子功能 0xE801。
此方法虽然简单,但功能也不强大,最大只能识别 4GB 内存,不过这对咱们 32 位地址总线足够了。
稍微有点不便的是此方法检测到的内存是分别存放到两组寄存器中的。低于 15MB 的内存以 1KB 为单位大小来记录,16~4GB则是用64kb为单位。
其中保留了1MB不使用也是为了兼容以前的设备(当做缓冲区)

  • 调用前输入
  1. AX: 子功能号:0xe801
  • 调用后输出
  1. CF位:若为0则调用未出错,1表示出错
  2. AX:以1kb为单位,只显示15MB以下的内存容量,即最大值为0x3c00 0x3c00*1024 = 15MB
  3. BX:以64k为单位,内存空间 16MB~4GB 中连续的单位数量,即内存大小为 BX*64*1024字节
  4. CX:同AX
  5. DX:同BX

下面介绍调用BIOS中断0x15的0x88需要的参数:

该方法使用最简单,但功能也最简单,简单到只能识别最大 64MB 的内存。即使内存容量大于 64MB,也只会显示 63MB
大家可以自己在bochs中试验下。为什么只显示到63MB呢?因为此中断只会显示1MB之上的内存,不包括这1MB,所以我们在使用的时候需要记得得加上 1MB。

  • 调用前输入
  1. AH: 子功能号:0x88
  • 调用后输出
  1. CF位:输出0表示调用未出错,1表示出错
  2. AX : 以1kb为单位大小,内存1MB以上的连续单位数量;内存大小=AX*1024字节 + 1MB

G1.2 代码实操

这里我没有完全按照书中的来写(保留了之前打印的字符串),基本每行代码都有注释(其实因为自己汇编还是太烂了,多写点防止忘记)

这次的新代码主要是添加了三种获取内存大小的方法(上一小节介绍的三种),并将结果保存在total_mem_bytes里面,通过填充,保证它的地址为0xb00,等会可以使用GDB调试查看内存大小(32MB),至于为什么是32MB,是因为在bochsrc.disk文件中配置的:megs: 32

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

; 构建gdt及其内部的描述符,拆分为上4字节,下4字节

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 ;此时 dpl 为 0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1

times 50 dq 0 ; 此处预留 50 个描述符的空位(为什么不是60?因为我开头调用jmp,使得total_mem_bytes无法刚好是0xb00) times 是 nasm 提供的伪指令,用来重复执行 times 后面表达式(编译器执行)

SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

times 0x200 - ($ -$$) db 0 ;填充0,使得total_mem_bytes 的节内偏移一定为0x200,地址一定为0xb00
total_mem_bytes dd 0
; total_mem_bytes 用于保存内存容量,以字节为单位,这个位置比价好记
; 当前偏移loader.bin文件头0x200 字节
; loader.bin加载地址为 0x900
; 所以 total_mem_bytes 内存地址为 0xb00
; 将来在内核中我们会引用这个地址,同时等会验证内存容量的时候,GDB调试也可以通过读取这个地址的内容来查看大小

;以下是 gdt 的指针,前 2 字节是 gdt 界限,后 4 字节是 gdt 起始地址
gdt_ptr:
dw GDT_LIMIT
dd GDT_BASE
loadermsg db 'Mosue'

;人工对齐计算大小:total_mem_bytes(4) + gdt_ptr(6) + ards_buf(239) + ards_nr(2) + loadermsg(5) = 256字节
ards_buf times 244 db 0 ;填充对齐使用
ards_nr dw 0 ;用来记录ARDS结构体数量


loader_start:

;----------------------0xe820 获取内存方法----------------------
;int 15h eax = 0000E820h , edx = 534D4150h ("SMAP") 获取内存布局
xor ebx,ebx ;第一次调用,ebx的值设置为0
mov edx,0x534D4150 ;赋值一次,以后循环的时候不会变化
mov di,ards_buf ;ards结构体缓冲区
.e820_mem_get_loop: ;循环或得每个ARDS结构体
mov eax,0x0000e820 ;执行int 0x15 之后,eax的值会变成0x534D4150,所以每次执行int之前都要更新为子功能号(即0x0000e820)
mov ecx,20 ;ARDS地址范围描述符的结构大小为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不为1,表示ards全部返回,也就是内存读取完毕了
jnz .e820_mem_get_loop

;在所有ards结构中,找出((base_add_low + length_low)的最大值,即内存的容量
mov cx,[ards_nr] ;遍历每一个ARDS结构体,循环次数为ARDS的数量(cx寄存器)
mov ebx,ards_buf
xor edx,edx ;edx即为最大内存容量,在此先请0
.find_max_mem_area:
mov eax,[ebx] ;base_add_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个ARDS结构
cmp edx,eax ;比较当前最大值(edx)和新计算的值(eax)
jge .next_ards ;冒泡思想找出最大,edx寄存器始终是最大的内存容量,如果edx>=eax,则跳过更新
mov edx,eax ;否则更新edx,edx 为总内存大小
.next_ards:
loop .find_max_mem_area ;直到(cx--)清零
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ax = 0xe801h 获取内存方法 最大支持4G----------------------
;返回后ax,cx只一样,kb为单位,bx,dx值一样,以64kb为单位
;在ax和cx寄存器中的为低15MB,bx和dx中为16MB到4GB
.e820_failed_so_try_e801:
mov ax,0xe801 ;选择0xe801方法
int 0x15 ;调用中断
jc .e801_failed_so_try_e88 ;如果调用失败,尝试使用0x88方法

;1.先计算低15MB的内存数量,并将kb转换为byte为单位
mov cx,0x400 ;cx与ax一样,cx用作乘数
mul cx ;dx:ax = 1024*ax
shl edx,16 ;edx << 16
and eax,0x0000FFFF ;eax &= 0x0000FFFF
or edx,eax ;edx = edx | eax 得到完整的地址
add edx,0x100000 ;ax只是15MB,所以要添加1MB
mov esi,edx ;先把低15MB的内存容量存取esi寄存器备份

;2.将16MB以上的内存转换为byte为单位
xor eax,eax ;将eax的值清零
mov ax,bx
mov ecx,0x10000 ;0x10000:64kb
mul ecx ;32位乘法,默认被乘数为eax,积为64位
;高32为存入edx,低32位存入eax,但是这个方法只能检测4GB,所以32位eax就够(edx一定为0),所以直接相加eax即可
add esi,eax ;将15MB以下的结果和低32位的结果相加
mov edx,esi ;edx即为总内存大小
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ah = 0x88 获取内存方法 最大支持64MB----------------------
;返回后ax,以kb为单位的内存容量,这里同样转换为byte,注意0x88子功能只会返回1MB以上的内容,所以最后要添加1MB
.e801_failed_so_try_e88:
mov ah,0x88 ;0x88方法
int 0x15 ;进入中断
jc .error_hlt ;如果调用失败,进入error_hlt
and eax,0x0000FFFF ;取低16位
mov cx,0x400 ;1024
mul cx ;dx:ax = cx*ax
shl edx ,16 ;edx << 16
or edx,eax ;edx = edx | eax
add edx,0x100000 ;添加1MB,edx就是最终内存了
jmp .mem_get_ok ;获取内存成功 其实可以不用跳转,因为下一个指令就是了

.mem_get_ok: ;获取内存成功
mov [total_mem_bytes],edx ;将内存大小(byte)写入total_mem_bytes保存
jmp .print_int

.error_hlt:
jmp $


;------------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述:打印字符串
;------------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若 AL=00H 或 01H)
;CX=字符串长度
;(DH、 DL)=坐标(行 列、 )
;ES:BP=字符串地址
;AL=显示输出方式
; 0—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置不变
; 1—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置改变
; 2—字符串中含显示字符和显示属性。显示后,光标位置不变
; 3—字符串中含显示字符和显示属性。显示后,光标位置改变
;无返回值
.print_int:
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ; ES:BP = 字符串地址
mov cx, 5 ; CX = 字符串长度
mov ax, 0x1301 ; AH = 13, AL = 01h
mov bx, 0x001f ; 页号为 0(BH = 0) 蓝底粉红字(BL = 1fh)
mov dx, 0x1800
int 0x10 ; 10h 号中断

;-------------------- 准备进入保护模式 -------------------------------
;1 打开 A20
;2 加载 gdt
;3 将 cr0 的 pe 位置 1

;----------------- 1. 打开 A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 2. 加载 GDT ----------------
lgdt [gdt_ptr]

;----------------- 3. cr0 第 0 位置 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'
mov byte [gs:161], 0x07 ; 灰底黑字
mov byte [gs:162], '2'
mov byte [gs:163], 0x07
mov byte [gs:164], 'L'
mov byte [gs:165], 0x07

jmp $

然后又是熟练的编译,写入,运行,GDB调试验证结果

1
2
3
4
5
6
7
8
nasm -I include/ -o loader.bin loader.S
# 注意这里count改成了3,,因为这个bin文件大小应该有个1070bytes
dd if=/home/mouse/OS_mouse/tool/bochs/mouse/loader.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=3 seek=2 conv=notrunc
#跳过运行的指令

#------打开GDB调试--------------
(gdb) x/xw 0xb00
#然后如果代码和配置文件都对的话,结果应该是0xb00: 0x02000000

这里的x/xw就是GDB的功能(以十六进制查看 0xb00处的 4 字节),得到的结果0x2000000转化为MB就是32MB,大家可以自己计算一下


G.2 内存分页,启动

在此之前,我们的程序虽然已经进了保护模式,地址空间到达了前所未有的 4GB,但其依然是受限制
的,就像共享网络带宽一样,此进程要和其他进程包括操作系统共享这 4GB 内存空间。我们把段基址+
段内偏移地址称为线性地址,线性地址是唯一的,只能属于某一个进程


G2.1 内存分页机制

一直以来我们都直接在内存分段机制下工作,目前未出问题看似良好,的确,目前咱们的应用过于简
单了,就一个 loader 在跑,能出什么问题呢?可是想象一下,当我们物理内存不足时会怎么办呢?比如系
统里的应用程序过多,或者内存碎片过多无法容纳新的进程,或者曾经被换出到硬盘中的内存段需要再次
重新装到内存,可是内存中找不到合适大小的内存区域怎么办

比较形象的例子就是:假设有三个进程A,B,C在运行,显然,我们会将他们三按顺序排好放在内存中,然后当B进程结束了,这个时候B的内存就释放了,可以这个时候如果来了一个进程D,它占用的内存大于进程B,也大于剩余的内存(总内存-进程A-进程B-进程C),但是小于内存中空闲的内存,那么实际上内存是够的,但是这个进程却是安排不进去的
解决办法有两种:

  1. 等待C进程结束,然后D进程再运行,但是这样会很浪费时间,因为谁也不知道C进程要运行多久
  2. 将A的进程/C的进程部分换到硬盘上,腾出可以容纳D的内存,可是有些硬盘的速度也很慢

  • 一级页表

首先分页要建立在之前说过的分段上面,经过段部件处理后,保护模式的寻址空间是 4GB(指线性地址空间),分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续

分页机制:
1.即可以将线性地址转换为物理地址
2.也可以用大小相等的页代表大小不等的段

通过线性地址到真实物理地址的映射,经过段部件输出的线性地址便有了另一个名字: 虚拟地址
在页表中保存着线性地址和物理地址的映射关系,页表中的每一行被称为页表项(4字节)

一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节
大小,所以这高 20 位的索引乘以 4 后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中
的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线
性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。

cpu中集成好的页部件用来专门计算这个

  • 二级页表

一级也页表目前的缺点有:

  1. 一级页表中最多可容纳(1M)个页表项,每个页表项为4字节,则是4MB
  2. 一级页表中的所有页表项必须提前建立好,因为操作系统要占用4GB中的高1GB,然后用户占3GB
  3. 每个进程都有自己的页表,进程一多,那么占用的空间也会很多

总结一下需要解决的问题是:不要一次性地将全部页表项建好,需要时动态创建页表项

专门有个页目录表来存储这些页表,每个页表的物理地址在页目录表中都以页目录项的形式存储(与表项一样),

具体大家可以看书中的讲解/网上查询,更加详细

  • 启动分页机制

启动分页机制的开关是将控制寄存器 cr0 的 PG 位置 1,PG 位是 cr0 寄存器的最后一位:第31位

PG 位为 1 后便进入了内存分页运行机制,段部件输出的线性地址成为虚拟地址(顺便说一下,第 0 位是 PE
位,用来进入保护模式的开关)。在将 PG 位置 1 之前,系统都是在内存分段机制下工作,段部件输出的线
性地址便直接是物理地址,也就意味着在第 2 步中,cr3 寄存器中的页表地址是真实的物理地址


G2.2 规划页表之操作系统与用户进程的关系

分页的第一步是要准备好一个页表

为了计算机安全,用户进程必须运行在低特权级,当用户进程需要访问硬件相关的资源时,需要向操作系统申请,由操作系统去做,之后将结果返回给用户进程。进程可以有无限多个,而操作系统只有一个,所以,操作系统必须“共享”给所有用户进程
页表的设计是要根据内存分布情况来决定的,我们也学习 Linux 的作法,在用户进程 4GB 虚拟地址空间的高 3GB 以上的部分划分给操作系统,0~3GB 是用户进程自己的虚拟空间。为了实现共享操作系统,让所有用户进程 3GB~4GB 的虚拟地址空间都指向同一个操作系统,也就是所有进程的虚拟地址 3GB~4GB 本质上都是指向的同一片物理页地址,这片物理页上是操作系统的实体代码

页目录表的位置,我们就放在物理地址0x100000 处(非必须,我就跟着作者来吧),咱们让页表紧挨着页目录表。页目录本身占 4KB,所以第一个页表的物理地址是 0x101000,下面就具体看看如何创建一个页目录和页表(这只是后面整体代码中的一个函数),同样也是启用内存分页进制三部曲之一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
;---------------------创建页目录和页表-------------------
; 页目录占用4kb,也就是4096字节(0x1000),PAGE_DIR_TABLE_POS是页目录的物理地址(0x100000)
;循环清零
setup_page:
mov ecx,4096
mov esi,0
.clear_page_dir: ;清理页目录
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;清零
inc esi ;偏移一个字节,计数+1
loop .clear_page_dir ;不断循环

;开始创建页目录(PDE)
.creat_pde: ; 创建 Page Directory Entry
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;此时的eax为第一个页表的位置和属性(偏移4kb)
mov ebx,eax ;此处为ebx复制,是为了.creat_pte做准备,ebx为基地址

; 下面将页目录项 0 和 0xc00 都存为第一个页表的地址,每个页表表示 4MB 内存
; 这样 0xc03fffff 以下的地址和 0x003fffff 以下的地址都指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax,PG_US_U | PG_RW_W | PG_P ;页目录项的属性 RW 和 P 位为 1,US 为 1,表示用户属性,所有特权级别都可以访问
mov [PAGE_DIR_TABLE_POS + 0X0],eax ;第一个目录项,在页目录表中的第 1 个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;0XC00是第768个页表占用的目录项(一个页表项占用4字节),0xc00以上的目录项用于内核空间
; 也就是说页表的 0xc0000000~0xffffffff 共计 1G 属于内核
; 0x0~0xbfffffff 共计 3G 属于用户进程
sub eax,0x1000 ;计算目录表自己的物理地址
mov [PAGE_DIR_TABLE_POS+4092],eax ;使最后一个目录项指向页目录表自己的地址

;创建页表项(PTE)
mov ecx,256 ;1M 低端内存 / 每页大小 4k =256
mov esi, 0
mov edx,PG_US_U | PG_RW_W |PG_P ;属性为7,US=1,RW=1,P=1
.creat_pte: ; 创建 Page Table Entry
mov [ebx+esi*4],edx ; 此时的edx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096 ; 4kb
inc esi ; 计数+1
loop .creat_pte ; 循环

;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;此时eax为第二个页表的位置,偏移8kb
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 的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4],eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret

G2.3 完整页表操作代码

下面就是整体loader.S的代码,成功运行后可以通过显存(GDT 的基址会变成 3GB 之上的虚拟地址,显存段基址也变成了 3GB 这上的虚拟地址)显示一个”V”
同时,我们的boot.inc文件也要稍微修改一下,添加点新的描述:

1
2
3
4
5
6
7
8
9
10
11
12
;/home/mouse/OS_mouse/tool/bochs/mouse/boot.inc
;.....略
;--------------------- loader & kernel -------------------------
PAGE_DIR_TABLE_POS equ 0x100000 ;页表的基地址
;.....略
;----------------------------------页表相关属性-----------------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
;.....略

然后就是添加页表的初始化,和新的GDT以及显存了(我这里保留了一些之前的打印代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

; 构建gdt及其内部的描述符,拆分为上4字节,下4字节

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 ;此时 dpl 为 0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1

times 50 dq 0 ; 此处预留 50 个描述符的空位(为什么不是60?因为我开头调用jmp,使得total_mem_bytes无法刚好是0xb00) times 是 nasm 提供的伪指令,用来重复执行 times 后面表达式(编译器执行)

SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

times 0x200 - ($ -$$) db 0 ;填充0,使得total_mem_bytes 的节内偏移一定为0x200,地址一定为0xb00
total_mem_bytes dd 0
; total_mem_bytes 用于保存内存容量,以字节为单位,这个位置比价好记
; 当前偏移loader.bin文件头0x200 字节
; loader.bin加载地址为 0x900
; 所以 total_mem_bytes 内存地址为 0xb00
; 将来在内核中我们会引用这个地址,同时等会验证内存容量的时候,GDB调试也可以通过读取这个地址的内容来查看大小

;以下是 gdt 的指针,前 2 字节是 gdt 界限,后 4 字节是 gdt 起始地址
gdt_ptr:
dw GDT_LIMIT
dd GDT_BASE
loadermsg db 'Mosue'

;人工对齐计算大小:total_mem_bytes(4) + gdt_ptr(6) + ards_buf(239) + ards_nr(2) + loadermsg(5) = 256字节
ards_buf times 244 db 0 ;填充对齐使用
ards_nr dw 0 ;用来记录ARDS结构体数量


loader_start:

;----------------------0xe820 获取内存方法----------------------
;int 15h eax = 0000E820h , edx = 534D4150h ("SMAP") 获取内存布局
xor ebx,ebx ;第一次调用,ebx的值设置为0
mov edx,0x534D4150 ;赋值一次,以后循环的时候不会变化
mov di,ards_buf ;ards结构体缓冲区
.e820_mem_get_loop: ;循环或得每个ARDS结构体
mov eax,0x0000e820 ;执行int 0x15 之后,eax的值会变成0x534D4150,所以每次执行int之前都要更新为子功能号(即0x0000e820)
mov ecx,20 ;ARDS地址范围描述符的结构大小为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不为1,表示ards全部返回,也就是内存读取完毕了
jnz .e820_mem_get_loop

;在所有ards结构中,找出((base_add_low + length_low)的最大值,即内存的容量
mov cx,[ards_nr] ;遍历每一个ARDS结构体,循环次数为ARDS的数量(cx寄存器)
mov ebx,ards_buf
xor edx,edx ;edx即为最大内存容量,在此先请0
.find_max_mem_area:
mov eax,[ebx] ;base_add_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个ARDS结构
cmp edx,eax ;比较当前最大值(edx)和新计算的值(eax)
jge .next_ards ;冒泡思想找出最大,edx寄存器始终是最大的内存容量,如果edx>=eax,则跳过更新
mov edx,eax ;否则更新edx,edx 为总内存大小
.next_ards:
loop .find_max_mem_area ;直到(cx--)清零
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ax = 0xe801h 获取内存方法 最大支持4G----------------------
;返回后ax,cx只一样,kb为单位,bx,dx值一样,以64kb为单位
;在ax和cx寄存器中的为低15MB,bx和dx中为16MB到4GB
.e820_failed_so_try_e801:
mov ax,0xe801 ;选择0xe801方法
int 0x15 ;调用中断
jc .e801_failed_so_try_e88 ;如果调用失败,尝试使用0x88方法

;1.先计算低15MB的内存数量,并将kb转换为byte为单位
mov cx,0x400 ;cx与ax一样,cx用作乘数
mul cx ;dx:ax = 1024*ax
shl edx,16 ;edx << 16
and eax,0x0000FFFF ;eax &= 0x0000FFFF
or edx,eax ;edx = edx | eax 得到完整的地址
add edx,0x100000 ;ax只是15MB,所以要添加1MB
mov esi,edx ;先把低15MB的内存容量存取esi寄存器备份

;2.将16MB以上的内存转换为byte为单位
xor eax,eax ;将eax的值清零
mov ax,bx
mov ecx,0x10000 ;0x10000:64kb
mul ecx ;32位乘法,默认被乘数为eax,积为64位
;高32为存入edx,低32位存入eax,但是这个方法只能检测4GB,所以32位eax就够(edx一定为0),所以直接相加eax即可
add esi,eax ;将15MB以下的结果和低32位的结果相加
mov edx,esi ;edx即为总内存大小
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ah = 0x88 获取内存方法 最大支持64MB----------------------
;返回后ax,以kb为单位的内存容量,这里同样转换为byte,注意0x88子功能只会返回1MB以上的内容,所以最后要添加1MB
.e801_failed_so_try_e88:
mov ah,0x88 ;0x88方法
int 0x15 ;进入中断
jc .error_hlt ;如果调用失败,进入error_hlt
and eax,0x0000FFFF ;取低16位
mov cx,0x400 ;1024
mul cx ;dx:ax = cx*ax
shl edx ,16 ;edx << 16
or edx,eax ;edx = edx | eax
add edx,0x100000 ;添加1MB,edx就是最终内存了
jmp .mem_get_ok ;获取内存成功 其实可以不用跳转,因为下一个指令就是了

.mem_get_ok: ;获取内存成功
mov [total_mem_bytes],edx ;将内存大小(byte)写入total_mem_bytes保存
jmp .print_int ;打印个mouse

.error_hlt: ;失败情况下
jmp $


;------------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述:打印字符串
;------------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若 AL=00H 或 01H)
;CX=字符串长度
;(DH、 DL)=坐标(行 列、 )
;ES:BP=字符串地址
;AL=显示输出方式
; 0—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置不变
; 1—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置改变
; 2—字符串中含显示字符和显示属性。显示后,光标位置不变
; 3—字符串中含显示字符和显示属性。显示后,光标位置改变
;无返回值
.print_int:
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ; ES:BP = 字符串地址
mov cx, 5 ; CX = 字符串长度
mov ax, 0x1301 ; AH = 13, AL = 01h
mov bx, 0x001f ; 页号为 0(BH = 0) 蓝底粉红字(BL = 1fh)
mov dx, 0x1800
int 0x10 ; 10h 号中断

;-------------------- 准备进入保护模式 -------------------------------
;1 打开 A20
;2 加载 gdt
;3 将 cr0 的 pe 位置 1

;----------------- 1. 打开 A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 2. 加载 GDT ----------------
lgdt [gdt_ptr]

;----------------- 3. cr0 第 0 位置 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

call setup_page ; 创建页目录及页表并初始化页内存位图

;要将描述符表地址及偏移量写入内存 gdt_ptr,一会儿用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来gdt所有的位置

;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx,[gdt_ptr +2]
or dword [ebx + 0x18 +4],0xc0000000
;视频段时第3个段描述符,每个描述符是8字节,即0x18
;段描述符的高 4 字节的最高位是段基址的第 31~24 位

;将 gdt 的基址加上 0xc0000000 使其成为内核所在的高地址
add dword [gdt_ptr + 2],0xc0000000

add esp,0xc0000000 ;将栈指针同样映射到内核地址

mov eax, PAGE_DIR_TABLE_POS ;把页目录地址赋给 cr3
mov cr3, eax

mov eax, cr0 ;打开 cr0 的 pg 位(第 31 位)
or eax, 0x80000000
mov cr0, eax

;在开启分页后,用 gdt 新的地址重新加载
lgdt [gdt_ptr] ;重新加载

;初始化gs寄存器
mov ax, SELECTOR_VIDEO ;这里要初始化gs寄存器,其实我尝试将它放在之前的位置初始化也可以打印成功好像
mov gs, ax

mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符 v 表示 virtual addr
jmp $

;---------------------创建页目录和页表-------------------
; 页目录占用4kb,也就是4096字节(0x1000),PAGE_DIR_TABLE_POS是页目录的物理地址(0x100000)
;循环清零
setup_page:
mov ecx,4096
mov esi,0
.clear_page_dir: ;清理页目录
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;清零
inc esi ;偏移一个字节,计数+1
loop .clear_page_dir ;不断循环

;开始创建页目录(PDE)
.creat_pde: ; 创建 Page Directory Entry
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;此时的eax为第一个页表的位置和属性(偏移4kb)
mov ebx,eax ;此处为ebx复制,是为了.creat_pte做准备,ebx为基地址

; 下面将页目录项 0 和 0xc00 都存为第一个页表的地址,每个页表表示 4MB 内存
; 这样 0xc03fffff 以下的地址和 0x003fffff 以下的地址都指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax,PG_US_U | PG_RW_W | PG_P ;页目录项的属性 RW 和 P 位为 1,US 为 1,表示用户属性,所有特权级别都可以访问
mov [PAGE_DIR_TABLE_POS + 0X0],eax ;第一个目录项,在页目录表中的第 1 个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;0XC00是第768个页表占用的目录项(一个页表项占用4字节),0xc00以上的目录项用于内核空间
; 也就是说页表的 0xc0000000~0xffffffff 共计 1G 属于内核
; 0x0~0xbfffffff 共计 3G 属于用户进程
sub eax,0x1000 ;计算目录表自己的物理地址
mov [PAGE_DIR_TABLE_POS+4092],eax ;使最后一个目录项指向页目录表自己的地址

;创建页表项(PTE)
mov ecx,256 ;1M 低端内存 / 每页大小 4k =256
mov esi, 0
mov edx,PG_US_U | PG_RW_W |PG_P ;属性为7,US=1,RW=1,P=1
.creat_pte: ; 创建 Page Table Entry
mov [ebx+esi*4],edx ; 此时的edx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096 ; 4kb
inc esi ; 计数+1
loop .creat_pte ; 循环

;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;此时eax为第二个页表的位置,偏移8kb
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 的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4],eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret

突然发现GDB调试不能访问像页表,GDT这些系统的寄存器,需要通过 ​Bochs 的远程调试协议​获取,所以反而Bochs自带的调试时可以使用info指令快速获取,所以后面调试选择先试用Bochs自带的调试器吧(方法可以参考书籍)

这里可以通过指令查看到GDT,以及显存段描述符的段基址,已经是新的虚拟地址了

编译,写入然后运行,在指令框中输入bochs的指令:info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<bochs:3> info gdt  #查看gdt相关内容
Global Descriptor Table (base=0xc0000903, limit=31): #这里的0xc0000903就是虚拟地址的gdt基地址,但是教程中是0xc0000900,暂时没找到为什么还有3的偏移(补:原因在下面)
GDT[0x00]=??? descriptor hi=0x00000000, lo=0x00000000
GDT[0x01]=Code segment, base=0x00000000, limit=0xffffffff, Execute-Only, Non-Conforming, Accessed, 32-bit
GDT[0x02]=Data segment, base=0x00000000, limit=0xffffffff, Read/Write, Accessed
GDT[0x03]=Data segment, base=0xc00b8000, limit=0x00007fff, Read/Write, Accessed #这里的0xc00b8000就是虚拟地址的显存段描述符的基地址
You can list individual entries with 'info gdt [NUM]' or groups with 'info gdt [NUM] [NUM]'

<bochs:4> info tab #查看虚拟地址的映射映射情况
cr3: 0x000000100000
0x00000000-0x000fffff -> 0x000000000000-0x0000000fffff
0xc0000000-0xc00fffff -> 0x000000000000-0x0000000fffff
0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff
<bochs:5>

这个0xc0000903的问题只能暂时搁置了,目前没有找到原因,如果有人知道原因可以在下方留言,非常感谢!

  • ps: 如果loader.S开头运用了jmp loader_start这一命令,gdt表的基地址就会发生偏移,可能会往后偏移,导致后面的tss添加的时候不能以0x900作为gdt基地址,我也是在这里出错的时候才想到这有问题,解决办法会在第十一章(我blog的第四节)

G2.4 快表 TLB 简介

分页机制虽然很灵活,但是要实现虚拟地址到物理地址的映射,还是很麻烦的(每一个虚拟地址到物理地址的转换都要重复以上过程)
所以处理器准备了一个高速缓存,可以匹配高速的处理器速率和低速的内存访问速度,它专门用来存放虚拟地址页框与物理地址页框的映射关系,这个调整缓存就是 TLB(即
Translation Lookaside Buffer,俗称快表)

指令 invlpg 的操作数也是虚拟地址,其指令格式为 invlpg m。注意,其中 m 表示操作数为虚拟内存地址,并不是立即数,比如要更新虚拟地址 0x1234 对应的条目,指令为 invlpg [0x1234],并不是 invlpg 0x1234。将来我们在内存管理系统中会涉及到 TLB 的更新操作,这一点应注意。

H 内核前的小结

经过前面漫长的学习,尤其是对于汇编不精的我来说也是相当路漫漫,终于到了有关内核相关的内容了,所以这个放到下一篇内容中记录学习
当然,在这个过程中,也是让我一个非科班的人了解了更多与计算机相关的知识(因为平常可能不太喜欢看啃别的长长的书)

(ps:好想要个实习)

参考文献

  1. [操作系统真象还原 (郑纲) (Z-Library)] — 大家可以自己在网上查找相关资源

留言

有问题请指出,你可以选择以下方式:

  1. 在下方评论区留言
  2. 邮箱留言
  • Title: 真象还原 --环境/准备 study(1)
  • Author: H_Haozi
  • Created at : 2025-10-21 15:45:00
  • Updated at : 2025-11-05 14:59:05
  • Link: https://redefine.ohevan.com/2025/10/21/os_elephant_one/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments