真象还原 --环境/准备 study(1)
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 DD = D+1ADD S,DD = S+DSUB S,DD = D-SOR S,DD = D|SAND S,DD = 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 | int absdiff(int x,int y) |
下面是通过汇编来实现–注意这里的x86asm只是为了代码高亮而使用的标识符(后面同理)
1 | # 假设 x 在 8(%ebp), y 在 12(%ebp) |
CPU对外设的操作通过专门的端口读写指令完成:
in al,21H: 从21H端口调取一字节到alout 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 |
|
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 |
|
当然,如果有其他报错或者警告,可以查看是缺少了,和上面一下安装对应的东西即可
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 | ############################################### |
前面说了其中硬盘设置的第二行是通过工具 bin/bximage 获得
1 | # 创建一个硬盘,类型为flat 大小为60MB 静默模式 |
执行命令后,会在The following line should appear in your bochsrc:的后面得到一串内容,我们直接更新到bochsrc.disk中即可
E.2 体验BIOS
书中对bios(Base Input & Output System)基本输入输出系统的描述很详细,这里就不过多赘述,主要总结几点
- cpu通过
cs:f000ip:fff0的预设然后会直接进入0x7C00地址执行 - 如果此扇区末尾的两个字节分别是魔数
0x55和0xaa,BIOS 便认为此扇区中确实存在可执行的程序(此程序便是久闻大名的主引导记录 MBR)
下面是书中的一个例子:在屏幕上打印字符串1 MBR
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S |
然后就可以通过 nasm 来到文件路径下来编译这个代码,并且放到磁盘中
1 | sudo apt-get install nasm #如果没下载 |
然后就会输出记录了1+0 的读入 记录了1+0 的写出 512 bytes copied, 0.000408059 s, 1.3 MB/s
1 | bin/bochs -f bochsrc.disk //通过文件 bochsrc.disk 运行虚拟机,然后回车即可,这个时候虚拟机会弹出另一个窗口 |
如果前面步骤没问题,那么弹出的窗口上便会有字符串1 MBR显示
E.3 编写MBR
通过书中从硬件到软件的介绍,现在改写之前的文件,将BIOS的输出改成通过显存输出
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S |
同之前的流程,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 | ; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S |
上面的代码成功实现了读取硬盘,同时将接力棒交给LOADER_BASE_ADDR地址的 loader 执行
这里引入了include的概念,所以还需要写一个boot.inc文件,我选择将它放在这个路径下的文件夹include里面
1 | ;/home/mouse/OS_mouse/tool/bochs/mouse/include/boot.inc |
同时,使用nasm编译的时候也需要更改指令,指定一下include的路径,最后用dd将bin文件写入硬盘
1 | nasm -I include/ -o mbr.bin mbr.S |
这个时候如果执行,因为LOADER_BASE_ADDR地址下还什么都没有,所以CPU直接跳到0x900第二问位置,也没有什么结果,可以等下面的简易loader写完之后再尝试运行
所以下一步就是实现内核加载器(loader)
E.6 内核加载器(loader)
同书中所讲,这里实现的loader是只在实模式工作,loader 是要经过实模式到保护模式的过渡,并最终在保护模式下加载内核,等后面学完保护模式后,再来进阶的
这里loader里面写一个和之前MBR非常接近的代码,只是修改字符串为2 LOADER
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S |
然后还是编译写入,但是这里的写入的扇区是2 seek=2,第0的扇区是MBR,第1个扇区是空的(原作者的爱好)
1 | nasm -I include/ -o loader.bin loader.S |
结果如下记录了0+1 的读入 记录了0+1 的写出 98 bytes copied, 0.000230323 s, 425 kB/s
然后就可以运行看看了,屏幕上会显示”2 loader”,当然这个loader并没有实际意义,只是来验证是否接力成功,最终的任务还是加载内核,从实模式到保护模式。
所以下面主要学习保护模式
F 保护模式
实模式下,访问的都是物理地址,而这样会造成很多问题,所以CPU工程师引入了保护模式,下面主要来简要记录保护模式的相关知识
- 实模式下操作系统和用户程序属于同一特权级,这哥俩平起平坐,没有区别对待。
- 用户程序所引用的地址都是指向真实的物理地址,也就是说逻辑地址等于物理地址
- 用户程序可以自由修改段基址,可以不亦乐乎地访问所有内存,没人拦得住。
- 访问超过 64KB 的内存区域时要切换段基址,转来转去容易晕乎。
- 一次只能运行一个程序,无法充分利用计算机资源。
- 共 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 | int al,0x92 |
- 保护模式的开关,CR0 寄存器的 PE 位
PE 为 0 表示在实模式下运行,PE 为 1 表示在保护模式下运行,所以
1 | mov eax,cr0 |
F.2 打开保护模式
- 首先要修改mbr.S文件中读入的扇区大小,因为loader.bin的大小会超过512字节,所以将
mov cx,1修改成 ``mov cx,4`
注意在后期如果loader.bin的文件大小超过了mbr读取的扇区数,也要修改这个参数,同时在写入磁盘的时候也要注意大小,之前我们不是已经改成8了嘛
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/mbr.S |
- 下一个修改文件是boot.inc,因为loader.S文件中用到的配置都是定义在这个文件中的符号,修改如下
其中equ 是 nasm 提供的伪指令,意为 equal,即等于,用于给表达式起个意义更明确的符号名,其指令格式是:符号名称 equ 表达式
1 | ;/home/mouse/OS_mouse/tool/bochs/mouse/boot.inc |
代码中具体的说明书中有详细介绍
- 最后一个文件是loader.S,如下
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S |
最后还是和之前一样编译运行,然后会在屏幕上看到第一行是原本在mbr.S文件中打印的字符,第二行的字符PZL是在保护模式打印的,最下面的字符串是在实模式用0x10中断打印的
1 | #编译 |
F.3 处理器微架构简介
F3.1 流水线
流水线是CPU 中的一个非常重要的技术,可以简单理解成CPU在并行执行任务,在”同一时间”用多级流水线将任务拆分,以高效率执行,举个简单例子:
指令执行单元 EU 是执行指令的唯一部件,一次只能执行一个指令,
单核 CPU 的情况下,只有一个指令处于执行中。CPU 中的各部分也是同时只能做一件事,但它们就像身体器官一样,也是在并行工作,相当于多个“人手”。CPU 的指令执行过程分为取指令、译码、执行三个步骤。
每个步骤都是独立执行的,CPU 可以一边执行指令,一边取指令,一边译码。CPU 中的时序不是秒,对 CPU 来说,秒就是天文数字。它的时序是时钟周期。
F3.2 乱序执行
乱序执行,是指在 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行,当然,得保证指令之间不具备相关性。
举个书中的例子:
1 | mov eax,[0x1234] |
这里的两条指令,因为ebx与eax相加需要依赖eax的值,所以必须等待第一步的mov操作结束,而相对内存访问比较慢,只能等着,顺序执行mov,然后是add
但是如果换一个代码:
1 | mov eax,[0x1234] |
这个时候会发现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 | ///home/mouse/OS_mouse/tool/bochs/mouse/drafts/while.c |
然后通过指令,用gcc生成对应的汇编文件,最后查看while.S
1 | gcc -S -o while.S while.c # -S表示编译成汇编语言,不进行汇编和链接 |
这里不是源文件,我将部分注释添加到了文件里面,方便理解,同时,这个生成的汇编语言并不是我们熟悉的 Intel 语法(ps:其实我都不熟悉),而是 AT&T 语法
1 | ; -------- 声明代码段,导出main函数符号 -------- |
如果分支预测错怎么办
也就是说,当前指令执行结果与预测的结果不同,这也没关系,只要将流水线清空就好了。因为处于执行阶段的是当前指令,即分支跳转指令。处于“译码”“取指”的是尚未执行的指令,即错误分支上的指令。只要错误分支上的指令还没到执行阶段就可以挽回,所以,直接清空流水线就是把流水线上错误分支上的指令清掉,再把正确分支上的指令加入到流水线,只是清空流水线代价比较大。
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需要的参数:
- 调用前输入
- EAX:子功能号,这里输入0XE820
- EBX:ARDS后续值,因为没执行一次中断只返回一中类型的ARDS结构,所以要记录下一个待返回的内存ARDS,第一次调用必须写入0,每次返回后,BIOS会更新该值
- ES:DI:ARDS缓冲区,BIOS 将获取到的内存信息写入此寄存器指向的内存,每次都以 ARDS 格式返回
- ECX:ARDS结构的字节大小,用来指定BIOS写入的字节数(调用者和 BIOS 都同时支持的大小是 20 字节,将来也许会扩展此结构)
- EDX:固定签名标记
0x534d4150
- 调用后输出
- CF位:若CF为0则表示调用为出错,为1表示调用出错
- EAX:字符串0x534d4150(和之前的签名标记对应)
- ES:DI: ARDS缓冲区地址,通输入值是一样的,返回时这个地址已经被BIOS填充了内存信息
- ECX: BIOS 写入到 ES:DI 所指向的 ARDS 结构中的字节数,BIOS 最小写入 20 字节
- EBS:下一个 ARDS 的位置。每次中断返回后,BIOS 会更新此值,BIOS 通过此值可以找到下一个待返回的 ARDS 结构,咱们不需要改变 EBX 的值,下一次中断调用时还会用到它。在 CF 位为 0 的情况下,若返回后的 EBX 值为 0,表示这是最后一个 ARDS 结构
下面介绍调用BIOS中断0x15的0xe801需要的参数:
另一个获取内存容量的方法是 BIOS0x15 中断的子功能 0xE801。
此方法虽然简单,但功能也不强大,最大只能识别 4GB 内存,不过这对咱们 32 位地址总线足够了。
稍微有点不便的是此方法检测到的内存是分别存放到两组寄存器中的。低于 15MB 的内存以 1KB 为单位大小来记录,16~4GB则是用64kb为单位。
其中保留了1MB不使用也是为了兼容以前的设备(当做缓冲区)
- 调用前输入
- AX: 子功能号:0xe801
- 调用后输出
- CF位:若为0则调用未出错,1表示出错
- AX:以1kb为单位,只显示15MB以下的内存容量,即最大值为0x3c00
0x3c00*1024 = 15MB - BX:以64k为单位,内存空间 16MB~4GB 中连续的单位数量,即内存大小为
BX*64*1024字节 - CX:同AX
- DX:同BX
下面介绍调用BIOS中断0x15的0x88需要的参数:
该方法使用最简单,但功能也最简单,简单到只能识别最大 64MB 的内存。即使内存容量大于 64MB,也只会显示 63MB
大家可以自己在bochs中试验下。为什么只显示到63MB呢?因为此中断只会显示1MB之上的内存,不包括这1MB,所以我们在使用的时候需要记得得加上 1MB。
- 调用前输入
- AH: 子功能号:0x88
- 调用后输出
- CF位:输出0表示调用未出错,1表示出错
- AX : 以1kb为单位大小,内存1MB以上的连续单位数量;内存大小=AX*1024字节 + 1MB
G1.2 代码实操
这里我没有完全按照书中的来写(保留了之前打印的字符串),基本每行代码都有注释(其实因为自己汇编还是太烂了,多写点防止忘记)
这次的新代码主要是添加了三种获取内存大小的方法(上一小节介绍的三种),并将结果保存在total_mem_bytes里面,通过填充,保证它的地址为0xb00,等会可以使用GDB调试查看内存大小(32MB),至于为什么是32MB,是因为在bochsrc.disk文件中配置的:megs: 32
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S |
然后又是熟练的编译,写入,运行,GDB调试验证结果
1 | nasm -I include/ -o loader.bin loader.S |
这里的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),但是小于内存中空闲的内存,那么实际上内存是够的,但是这个进程却是安排不进去的
解决办法有两种:
- 等待C进程结束,然后D进程再运行,但是这样会很浪费时间,因为谁也不知道C进程要运行多久
- 将A的进程/C的进程部分换到硬盘上,腾出可以容纳D的内存,可是有些硬盘的速度也很慢
- 一级页表
首先分页要建立在之前说过的分段上面,经过段部件处理后,保护模式的寻址空间是 4GB(指线性地址空间),分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续
分页机制:
1.即可以将线性地址转换为物理地址
2.也可以用大小相等的页代表大小不等的段
通过线性地址到真实物理地址的映射,经过段部件输出的线性地址便有了另一个名字: 虚拟地址
在页表中保存着线性地址和物理地址的映射关系,页表中的每一行被称为页表项(4字节)
一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节
大小,所以这高 20 位的索引乘以 4 后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中
的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线
性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。
cpu中集成好的页部件用来专门计算这个
- 二级页表
一级也页表目前的缺点有:
- 一级页表中最多可容纳(1M)个页表项,每个页表项为4字节,则是4MB
- 一级页表中的所有页表项必须提前建立好,因为操作系统要占用4GB中的高1GB,然后用户占3GB
- 每个进程都有自己的页表,进程一多,那么占用的空间也会很多
总结一下需要解决的问题是:不要一次性地将全部页表项建好,需要时动态创建页表项
专门有个页目录表来存储这些页表,每个页表的物理地址在页目录表中都以页目录项的形式存储(与表项一样),
具体大家可以看书中的讲解/网上查询,更加详细
- 启动分页机制
启动分页机制的开关是将控制寄存器 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 | ;---------------------创建页目录和页表------------------- |
G2.3 完整页表操作代码
下面就是整体loader.S的代码,成功运行后可以通过显存(GDT 的基址会变成 3GB 之上的虚拟地址,显存段基址也变成了 3GB 这上的虚拟地址)显示一个”V”
同时,我们的boot.inc文件也要稍微修改一下,添加点新的描述:
1 | ;/home/mouse/OS_mouse/tool/bochs/mouse/boot.inc |
然后就是添加页表的初始化,和新的GDT以及显存了(我这里保留了一些之前的打印代码)
1 | ; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S |
突然发现GDB调试不能访问像页表,GDT这些系统的寄存器,需要通过 Bochs 的远程调试协议获取,所以反而Bochs自带的调试时可以使用info指令快速获取,所以后面调试选择先试用Bochs自带的调试器吧(方法可以参考书籍)
这里可以通过指令查看到GDT,以及显存段描述符的段基址,已经是新的虚拟地址了
编译,写入然后运行,在指令框中输入bochs的指令:info
1 | <bochs:3> info gdt #查看gdt相关内容 |
这个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:好想要个实习)
参考文献
- [操作系统真象还原 (郑纲) (Z-Library)] — 大家可以自己在网上查找相关资源
留言
有问题请指出,你可以选择以下方式:
- 在下方评论区留言
- 邮箱留言
- 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.