拼一个自己的操作系统(SnailOS 0.03的实现)

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
  1. 保护模式及其重新设置

看到这里相信80x86的保护模式大家都应该听说过吧。是啊为什们我们不象其他讲操作系统实践的书那样在很早的时候就大讲特讲intel体系结构中的保护模式呢好了不卖关子了。原因其实也是很简单的因为我们已经进入了保护模式而且grub已经为我们准备了全局描述符表以及表中的几个关键描述符了。现在就让我们用virtual box的调试模式看看这张表格吧下面是调试器返回的信息我们粘在了这里。

VBoxDbg> info gdt

Guest GDT (GCAddr=00000000000010b0 limit=20):

0010 - 0000ffff 00cf9b00 - base=00000000 limit=ffffffff dpl=0 CodeER Accessed Present Page 32-bit

0018 - 0000ffff 00cf9300 - base=00000000 limit=ffffffff dpl=0 DataRW Accessed Present Page 32-bit

我们用在command框中输入info gdt命令就会得到GDT的基地址以及GDT的长度接下来的两条信息就是两个描述符的信息了。同时详细信息也告诉我们两个段的选择符分别是0x0010和0x0018接下来是描述符的16进制形式基地址是0段的长度是4G描述符的特权级是0级分别是已被访问可读可执行的代码段和已被访问能够被读写的数据段。

我知道在没有讲解全局描述符表、描述符结构、已经全局描述符寄存器之前跟大家说上述的东西简直是让大家读天书一样会丈二和尚摸不着头脑。不过话又说回来那些有些难度的问题早晚是要出现在笔者的书中的还不如早说出来一吐为快让大家觉得也没有什么难度——根本不是什么难题就好了。下面我们就要说清楚。

为了保持兼容性intel的老爷爷们真的是没少下功夫这不依然延续了以前16位cpu的分段机制。而且在默认的情况下cpu是处于实模式的。即使当我们进入了保护模式分段机制仍然是我们不得不用的这个没有什么选择权了。那么这次的分段机制还是有些不同的。为了描述一个段设计了8字节的描述符结构为了存储这些8字节的结构设计了一个大数组连续存储这些描述符即所谓的描述符表。那么表的起始地址从哪里得来呢没有办法只能从内存中得来在内存得什么地方呢哦这是个好问题呦这就是那些书上都不会讲得东西了。当然是我们操作系统自己指定了。言外之意是我们在编程的代码中指定。可是这么重要得结构放在内存中cpu怎么就知道了呢全局描述符表寄存器应运而生在cpu中把它存储下来为了这个设计者还专门得研究了两条指令lgdr和sgdt分别是加载内存中的描述符表的数据结构和填充覆盖这个数据结构的指令。这样大家明白了吧描述符是详细说明一个段的数据结构描述表是一个又很多这样的数据结构的大数组。数组的初始地址在内存中被gdtr加载后供cpu使用。

而一个段描述符又有那些要说的呢这里我们只说段的描述符而且是全局描述表中的。也就是说还有其他类型的描述符还有其他类型的描述符表这个大家一定要注意那些负复杂的东西还是等到用的时候在细细将来吧。说到段这里的段与16位cpu的段并没有什么根本的区别之所以分段还是要让为我们编程者处理不同类型的地址所提供的功能。更深层次的含义是我们没有必要总使用物理地址来编程如果那样的话4G字节的物理内存我们不知道要记住多少的地址了。分段了以后所有到的地址都是以段为起点所以这样无论对于操作系统的编写者还是应用程序的编写者都是福音吧而且大家注意了分段是随意的并不是说这个段的地址与那个段的地址不能有重叠而是说你分你的我分我的重叠就重叠干扰就干扰就看大家怎么用了这不上面的代码段和数据段基地址和段限长都是完全重合的只不过属性中说了你是代码段我是数据段。看来分段的目的并不是为了隔绝而是另有一番想法了。正是这样我们可以看到代码段是可执行只读的因此当我们试图向代码段中写入数据时因为现在还没有异常处理的代码加入所以cpu会毫不留情的宕机。可是我们代码一点问题都没有啊聪明的你通过上面的提示一定已经想到了写入内存的操作当然是通过数据段的读写来操作的了。可是我们根本没有指定当前的代码段和数据段的代码呀。这其实还是grub的功劳了。在进入保护模式之前这些烦心的工作都要不折不扣的做了否则哈哈否则就不可能做到这里了。

现在告诉大家也不晚吧进入保护模式前在grub中一定会有类似下面这样的指令组合。

mov ax,0x0018

mov ds, ax

mov es, ax

mov fs, ax

mov gs, ax

mov ss, ax

mov esp, stack

jmp dword 0x0010:kernel_start

上面的ax、ds、es、fs、gs等都是cpu中的寄存器寄存器大家都知道吧不知道也没有关系cpu做运算可不会轻易使用内存这些低速的存储装置来操作首先是用寄存器但是这些内部存储单元就那么几个只是来回的复用因此又有片内缓存就是某些内存的镜像。

ds、es、fs、gs、ss分别就是段寄存器了只不过这次它们中装的不是段的基地址而是描述符在描述表中的相对地址。这个地址我们怎么算来呢当然是用减法了就是用某个描述表项的逻辑地址减去描述符表的初始逻辑地址。为什么又不是简单的物理地址了呢这还要往前说在16位模式我们已经使用那时的分段机制了因此它并不等价于物理地址的减法很可能是两个逻辑地址的减法当然如果段的基地址是从0开始的那物理地址和逻辑地址也就一样了。

终于到了该详细的讲解描述符这个数据结构的时候了。不过这个东西还是蛮枯燥的真的是让人头痛的地方呀。

图中清晰的展示了段描述符的每一个细节并且精细到了每一位。然而我们的大脑却不清晰了。仔细看来段基地址被拆成了三部分段限长被拆成了两部分还有很多莫名奇妙的属性位。本来很整齐的描述符为什么要搞得如此复杂以致于我们刚刚厘清的头绪一下子又乱作一团。

谁说不是呢Intel的老爷爷们为了兼容性把简单的东西搞得如此复杂也不得不教人佩服地五体投地了。好了光是心烦意乱也于事无补无奈之下我们还是要详细地看看每个属性代表的意义。

让我们从高处的位开始说起吧。属性所在的那个双字中第23位是G位被称为粒度位。这真是个无厘头的名称呀这和粒度有什么关系。但当你看到段限长的位数时你就会有那么一点想法了吧段的最大长度可以是4G段限长居然是20位的这又是在搞什么鬼熟悉16位编程的你一定想到了什么吧那时候段寄存器的长度是16位的却能够访问20位的物理地址。现在段限长是20位又要表示长度位4G的段。作弊他们每次都在作弊吗这个粒度位就给它们作弊创造了条件当该位为0时段限长的数值就乘以1字节而为1时段限长的数值就要乘以4k字节。想必大家现在像清楚了吧intel的老爷爷们是真会省啊D/B位是一个不好解释的问题首先说这个位之所以有两个名字的原因是因为在该段作为代码段或是数据段时该位的含义有明显的不同。

这里要补充一点的时在全局描述符表中从大类上来说可以有两种不同的描述符一种是系统段描述符另一种就是我们现在讲的非系统段描述符。而非系统段描述符就是我们现在讲的代码段和数据段的描述符。

好的让我们接着非系统段的代码段和数据段的描述符来说一说D/B这一位吧。如果是代码段这一位就应该称之为D位D位为0是处理器认为该代码段中的偏移地址或操作数是16位的而为1时认为是32位的。如果是数据段这一位则应称之为B为该段是可以作为程序运行中的栈使用的如果为0像call、push、pop、ret等可以操作栈的指令对该段进行操作时用的是sp指针否则就会用32位的esp指针。

L位是预留给64位CPU使用的64位代码标志虽然我们现在用的绝大多数CPU都是64位的但是我们为了简单起见仍然是用32位指令所以该位应该被简单的置0就好了。

AVL是一个预留给软件使用的位如果你的操作系统软件对段有什么特殊的要求是可以用该位来标记的。

P为是段的存在位显然我们应该把正在使用的段描述的该位置为1。否则处理器会产生异常当然现在的我们还没有处理异常的等代码存在所以处理器会宕机。

DPL这两位是描述符的特权级特权级3到0依次升高关于特权级的对段的访问由于过于复杂我们还是在用到的时候再讲吧。

S是指定描述符大类的标志就是我们上面所说的系统段描述或非系统段描述符为0时表示系统段描述符否则为1时表示非系统段描述符也就是我们这里的代码段或数据段描述符。

TYPE这四位正是用于对描述符的进一步分类也就是到了这里我们才把非系统段区分为代码段或是数据段这是通过TYPE的最高位来实现的该位为0则是数据段为1则是代码段。根据此位的不同接下来紧挨着的3位又会有不同含义。如果被TYPE最高位置0确定了数据段的地位则次高位表示段的读写方向正常情况下我们的数据当然是从低地址往高地址的读写顺序为妙可是如果是纯堆栈段就是向下生长的因此这里可能特指定义了一个纯正的堆栈段供程序使用。次高位为0是一般的数据段为1是向下读写的数据段堆栈段不过我觉得很少有人专门设立堆栈段。而是通常是把堆栈指针esp指向普通数据段的高处就好了。TYPE中次高位后面的1位是可读写标志这意味着该位置0时是不可以写这个数据段的但是可读只有置1了才能够又读又写该数据段。最后一位是访问与否标志由处理器维护通常用于统计该段的使用频次一般的我们在初始时置0表示该段未被访问过。

当TYPE的最高位置1时该段就是代码段次高位的涵义由此变成了一致代码段和非一致代码段这个问题比较深奥还是等将来在讲吧。而次高位之后的位代表是否代码可读。为0时表示该段不可以像数据一样被读出为1时则表示可以读出。至于是否可以写入相信大家没有异议吧代码段在保护模式下始终时认为不可写入的。如果你想要写入则必须定义一个重合的数据段来操作。而最后一位与数据段的最后一位完全相同。

也许你差不多忘了吧系统还新增了一个全局描述表寄存器了它可是装着重要的东西也就是全局描述符表的物理基地址以及长度哦。奇怪的是在32位体系结构中它是一个6字节的装置他获得物理地址的方式是读取内存也就是说在开始16位实模式时也用不到该表如果没有全局描述符表我们就必须事先准备好一个在内存中的描述结构供加载全局描述符表的指令使用我们把这个6字节的结构称之为伪寄存器描述符够绕嘴的吧。该描述符的低2字节是全局描述符表的长度高4字节是全局描述符表的物理基地址。长度为2字节大家是不是想到了什么。对头晓得也就是说最多可以装81922的16次幂再除以8个描述符。

哦对了最后的最后还有一个段选择符的事情。下面的是选择符的结构。

选择符在很多书上称为选择子不过我习惯称之为选择符。这里重点说的就是描述符的偏移地址书上大多都称之为描述符索引但我认为该数值已经天然的左移了三位乘以8所以就是一个偏移地址吧。TI位是表示的是在全局描述符表中还是在局部描述表中的描述符由于我们在今后始终不用局部描述符所以该位始终为0即是全局描述符表中的描述符。RPL被成为请求特权级关于这个现在用不着这两位都置0好了表示在最高特权级0下工作。

哎呀妈呀可算是拽完了真的是又臭又长啊。讲到这里初次接触保护模式的你一定会有一种感觉那就是——也太特么难了吧。如果没有恭喜你天才呀稍加努力以后一定是顺风顺水了。

如果你脸有难色又非常郁闷的话让我来告诉你一个好消息吧。虽然咱们讲的挺热闹甚至是口水满天飞。但是编起程序来也就那么简单的几十行代码了。在这里我们还是新添加了sysasm.asm、gdt_idt_init.h、gdt_idt_init.c三个文件还是请看下面吧

【system.asm】

; system.asm 创建者至强 创建时间2022年8月

bits 32

global _lgdtr, _reset_gdt

; 下面的函数是加载伪寄存器描述符到全局描述表寄存器中的函数

align 16

_lgdtr: ; void lgdtr(struct gdtr* pgdtr)

; 由于没有对此时的堆栈进行任何操作所以当前栈指针

; 指向函数的返回地址。栈指针加4后则指向第一个参数

; 这个参数正是伪寄存器描述符的地址。

mov eax, [esp + 1 * 4]

lgdt [eax]

ret

align 16

_reset_gdt: ; void reset_gdt(void)

; 通过段选择符来更新各段寄存器的内容0x10是全局描述

; 表中的第三个选择符所有的数据段都更新为该段。由于

; intel处理器的"怪癖"往段寄存器中写入数据必须借助

; 其他的寄存器这里按照惯例使用了ax。还是"怪癖"更新

; cs代码段寄存器不能使用mov指令而要jmp指令并且

; 采用了双字修饰符形式的段间跳转双字形式不是必须的但段

; 间跳转是必须的因为只有这样处理器才会更新cs。在32位

; 模式下nasm会把不带双字修饰符的段间跳转指令默认汇编成

; 正确的格式。

mov ax, 0x10

mov ds, ax

mov es, ax

mov fs, ax

mov gs, ax

mov ss, ax

jmp dword 0x08 : .1

.1:

ret

【gdt_idt_init.h】

// gdt_idt_init.h 创建者至强 创建时间2022年8月

#ifndef __GDT_IDT_INIT

#define __GDT_IDT_INIT

// 定义了全局描述符表寄存器的伪描述符并且告诉编译器

// 尊重我们对结构的定义不要做任何尺寸的改动。

struct gdtr {

unsigned short limit;

unsigned int base;

}__attribute__((packed));

void lgdtr(struct gdtr* pgdtr);

void reset_gdt(void);

void create_gdt_desc(unsigned short gdt_nr, unsigned int base,

unsigned short attr, unsigned int limit);

void gdt_init(void);

#endif

【gdt_idt_init.c】

// gdt_idt_init.c 创建者至强 创建时间2022年8月

#include "gdt_idt_init.h"

// 构造一个全局描述符的函数该函数需要4个参数

// 一是描述符在全局描述符表中的下标二是描述符所描述段

// 的基地址三是段的各种属性四是段限长。

void create_gdt_desc(unsigned short gdt_nr, unsigned int base,

unsigned short attr, unsigned int limit) {

// 凭感觉确定的全局描述表的基地址。 、

unsigned long long* gdt_start = (unsigned long long*)(0x6000);

unsigned long long base_low = base & 0x000000000000ffff;

unsigned long long base_mid = base & 0x0000000000ff0000;

unsigned long long base_high = base & 0x00000000ff000000;

unsigned long long limit_low = limit & 0x000000000000ffff;

unsigned long long limit_high = limit & 0x00000000000f0000;

unsigned long long attrib = attr & 0x000000000000f0ff;

gdt_start[gdt_nr] = limit_low | (limit_high << 32) |

(base_low << 16) | (base_mid << 16) | (base_high << 32) |

(attrib << 40);

}

void gdt_init(void) {

struct gdtr gdtr_;

int i;

// 与上边函数的基地址相对应。

gdtr_.base = 0x6000;

gdtr_.limit = 256 * 8 - 1;

// 全局描述表中第一个描述符必须初始化为0

create_gdt_desc(0, 0, 0, 0);

// 0x08是全局描述符表中第一个描述符的选择符从0开始计算

create_gdt_desc(1, 0x0, 0xc09a, 0xffffffff); //代码段

create_gdt_desc(2, 0x0, 0xc092, 0xffffffff); //数据段

// 其余预留的描述符空间目前全部初始化为0。

for(i = 3; i < 256; i++) {

create_gdt_desc(i, 0, 0, 0);

}

// 重新加载全局描述符表寄存器。

lgdtr(&gdtr_);

// 更新内核所用的各段地址其实就是说说罢了。我们虽然更新

// 了但是更新后的段跟grub甚至的基地址以及段限长完全一样

// 的。同时这个函数的名字也是文不对题暂且就这样吧。

reset_gdt();

}

相信通过上述代码的注释以及之前对于保护模式下全局描述符表的有关解释大家应该可以初步的了解这些设置的作用了。哦对了除了上述代码大家还要在kernel.c中加入相应的头文件以及函数调用。下面就让我们通过virtual box的调试功能进一步了解一下到底我们做了些什么。下面是调试器返回的最新的全局描述符表的信息。

VBoxDbg> info gdt

Guest GDT (GCAddr=0000000000006000 limit=7ff):

0008 - 0000ffff 00cf9b00 - base=00000000 limit=ffffffff dpl=0 CodeER Accessed Present Page 32-bit

0010 - 0000ffff 00cf9300 - base=00000000 limit=ffffffff dpl=0 DataRW Accessed Present Page 32-bit

通过和上面同样的信息来对比大家发现了吧全局描述符表的基地址已经从0x10b0变成了我们凭感觉自定义的0x6000了段限长也从原来的0x20变为0x7ff。这说明我们的操作是成功的。

到了这里大家可能有一个问题要问了为什么我们在创建描述符的函数中把段属性设置为0xc09a用代码段举例数据段也一样的到了调试信息中却变成了0xcf9b了呢如果你有真个问题说明你非常认真仔细的对比了代码和调试信息真是辛苦了。那就要对照描述结构一位一位的抠出个所以然了。咱们先说我们的设置0xc09a的二进制形式是

1100_0000_1001_1010 B可以通过windows自带的计算器获得也可以用咱们提供的printf_()函数在信息去打印出来。通过对照描述符结构你会发现该段是一个4k粒度的、指令和操作数长度为32位的、存在的、特权级为0、非系统段的、非一致的、可读和执行的、未被访问过的代码段。而0xcf9b的二进制形式是1100_1111_1001_1011 B与上面的比起来第一个下划线后面的四位全部变成了1也是对照描述符结构这里原来是段限长的高4位不属于属性位之所以后来变成了0xf相信大家已经知道了吧因为我们的段限长设置的就是4G-1从零开始计算所以这里必须是全部为1。再一个变化就是最后一位从最表高位开始计算变成了1对照一下恍然大悟吧这是由处理器维护的已访问位显然程序运行到这里该段已经被访问无数次了。

总结......保护模式中即使是最原始的部分对我们初学来说也是有着极大的挑战性的大家要是想继续的话一定要坚持呀坚持就是胜利。翻过了这座山跨过了那条河美景才能在心中留下深刻地印象不经历风雨怎么见彩虹吗

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6