1、创建GDT表及其表项
在进入保护模式时,当时临时创建了一个简易的GDT表,这里重新创建GDT表和表项。
在进入保护模式后,所有的内存访问操作,都是通过GDT表(也包含LDT表),GDT表每项称为段描述符,具体结构如下:
GDT表大致分为三部分:base addr(基地址)、limit(界限)、一些属性值
GDT表最大为64KB,每个表项为8字节。在GDT表设置好后,需要通过lgdt()将表的信息加载到GDTR寄存器中。
GDTR寄存器包含两部分:
基址(Base Address):GDT 在内存中的起始地址。
限制(Limit):GDT 的大小减去 1(以字节为单位)。
GDTR 是一个 CPU 寄存器,用于存储 GDT 的基址和大小,指定了CPU 在 GDT 中查找描述符的位置和 GDT 的总大小。
相关代码实现(cpu.c 和 cpu.h)
/**
* 设置段描述符
*/
void segment_desc_set(int selector, uint32_t base, uint32_t limit, uint16_t attr) {
segment_desc_t * desc = gdt_table + selector / sizeof(segment_desc_t); //将指针 desc 指向 gdt_table 中对应索引位置的段描述符
//设置段描述符的各个字段
desc->limit15_0 = limit & 0xffff;
desc->base15_0 = base & 0xffff;
desc->base23_16 = (base >> 16) & 0xff;
desc->attr = attr | (((limit >> 16) & 0xf) << 8);
desc->base31_24 = (base >> 24) & 0xff;
}
/**
* 初始化GDT
*将这段代码将所有 GDT 表项清空,确保没有旧的数据残留,为后续的段描述符设置做好准备。
*/
void init_gdt(void) {
// 全部清空
for (int i = 0; i < GDT_TABLE_SIZE; i++) {
segment_desc_set(i * sizeof(segment_desc_t), 0, 0, 0);
}
}
/**
* CPU初始化
*/
void cpu_init (void) {
init_gdt();
}
#pragma pack(1) //告诉编译器按照 1 字节对齐来存储结构体中的成员变量。
/**
* GDT描述符
*/
typedef struct _segment_desc_t {
uint16_t limit15_0; //它表示段的大小范围,最大可表示 64KB 的段
uint16_t base15_0;
uint8_t base23_16;
uint16_t attr; //段的属性(attr),包含段类型、描述符特权级别(DPL)、段存在位等信息
uint8_t base31_24;
}segment_desc_t;
#pragma pack() //恢复结构体默认的对齐方式
void cpu_init (void);
void segment_desc_set(int selector, uint32_t base, uint32_t limit, uint16_t attr);
2、保护模式下的内存管理简介
1)IA-32 内存管理机制
- 分段(Segmentation):将内存划分为若干段,每个段都有起始地址和长度,并用来提供不同的内存视图,如代码段、数据段和堆栈段等。
- 分页(Paging):将内存划分为固定大小的页(通常是 4KB),并使用页表将虚拟地址映射到物理地址,以支持虚拟内存管理和地址空间隔离。
即段式存储与页式存储
2)段式存储特点
-
将线性地址空间转变成多个段(segments)
-
每个段带有相关的保护机制
-
有多种类型的段:数据、代码、栈、门、tss
-
使用的地址为逻辑地址,即段选择子+偏移
3)页式存储的特点
- 将线性地址转换为逻辑地址
- 在较小的内存上实现更大的虚拟内容
- 按需加载等功能
4)平坦模型
IA32的段式存储比较复杂,因此使用平坦模型:所有的内存段(数据段、代码段、栈段等)都设置成从0地址开始,长度覆盖到最大地址空间(4GB)
以下是实现平坦模型的基本步骤:
-
初始化 GDT(全局描述符表):设置代码段和数据段描述符的基址为 0,段长度为 4GB,权限设定为允许读取和执行。
-
设置段寄存器:将所有的段寄存器(
CS
、DS
、SS
、ES
、FS
、GS
)指向 GDT 中的相应段描述符。 -
启用分页:通过控制寄存器 CR0 和页表来启用分页模式,提供虚拟内存管理功能
5)相关代码
/**
* 初始化GDT
*/
void init_gdt(void) {
// 全部清空
for (int i = 0; i < GDT_TABLE_SIZE; i++) {
segment_desc_set(i * sizeof(segment_desc_t), 0, 0, 0);
}
//数据段
segment_desc_set(KERNEL_SELECTOR_DS, 0x00000000, 0xFFFFFFFF,
SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_DATA
| SEG_TYPE_RW | SEG_D);
// 只能用非一致代码段,以便通过调用门更改当前任务的CPL执行关键的资源访问操作
segment_desc_set(KERNEL_SELECTOR_CS, 0x00000000, 0xFFFFFFFF,
SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_CODE
| SEG_TYPE_RW | SEG_D);
// 加载gdt
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
}
设置的权限:
#define SEG_G (1 << 15) // 设置段界限的单位,1-4KB,0-字节
#define SEG_D (1 << 14) // 控制是否是32位、16位的代码或数据段
#define SEG_P_PRESENT (1 << 7) // 段是否存在
#define SEG_DPL0 (0 << 5) // 特权级0,最高特权级
#define SEG_DPL3 (3 << 5) // 特权级3,最低权限
#define SEG_S_SYSTEM (0 << 4) // 是否是系统段,如调用门或者中断
#define SEG_S_NORMAL (1 << 4) // 普通的代码段或数据段
#define SEG_TYPE_CODE (1 << 3) // 指定其为代码段
#define SEG_TYPE_DATA (0 << 3) // 数据段
#define SEG_TYPE_RW (1 << 1) // 是否可写可读,不设置为只读
6)逻辑地址、线性地址、物理地址关系
逻辑地址是由程序生成的地址,又称为虚拟地址。由程序员或编译器在代码中显式或隐式地定义
逻辑地址包含两个部分:
1)段选择子(Segment Selector):指定内存的哪个段(如代码段、数据段、栈段)被访问。段选择子是一个指向段描述符表(如 GDT 或 LDT)的索引。
2)段内偏移量(Offset):指定段内的偏移量或地址。
线性地址是逻辑地址经过分段(Segmentation)机制转换后得到的地址。它是一个统一的内存地址空间中的地址
在使用 x86 的分段机制时,线性地址的计算方式如下:
- 从段描述符中获取段的基址(Base Address)。
- 将段的基址与逻辑地址中的段内偏移量相加,得到线性地址。
物理地址是最终用于访问内存芯片的地址,也就是内存单元的实际地址。它是线性地址经过分页(Paging)机制转换后得到的地址。
7)逻辑地址到线性地址的转换
在程序进行内存访问时,会进行逻辑地址的转换,转换到线性地址(暂不考虑分页机制),转换过程如下:
3、重新加载GDT
之前创建的GDT表有些细节并未处理,而且原表项太小,以及该内存有其他用途,如栈
GDT表项格式
以下仅界限表项的属性部分:
- S:1-代码段或数据段,0 - 系统段
- DPL:特权级,取值0-3
- P:段是否存在,1存在,0不存在
- D/B:指示代码或栈的大小是32位还是16位,1 - 32位,0-16位
- G:段界限的单位。1 - 界限的单位是4KB,0 - 字节。
- L和AVL:我们不没有用到,使用0即可。
- type:见下表,用于描述段的具体类型
在原来的基础上设置了门描述符
/**
* 设置门描述符
*/
void gate_desc_set(gate_desc_t * desc, uint16_t selector, uint32_t offset, uint16_t attr) {
desc->offset15_0 = offset & 0xffff;
desc->selector = selector;
desc->attr = attr;
desc->offset31_16 = (offset >> 16) & 0xffff;
}
/*
* 调用门描述符
*/
typedef struct _gate_desc_t {
uint16_t offset15_0;
uint16_t selector;
uint16_t attr;
uint16_t offset31_16;
}gate_desc_t;
4、触发运行中的异常(除0异常)
5、添加中断门描述符
中断们描述符的配置:
- 需要准备一个IDT表,并通过LIDT指令加载到IDTR寄存器中。
- 在IDT表中,通过选择子索引到对应的GDT表象,进而定位到中断处理程序的基地址。
- 加上偏移量后,可以得到中断处理程序在内存中的具体地址
6、初始化IDT表
IDT表的作用与配置
- 进入保护模式后,CPU需要一个新的向量表,即IDT表,用于异常和中断处理。
- DT表通过IDTR寄存器指向,需要提供符合中断门描述符的表格。
- IDT表中的每个条目需要填入选择子和偏移量,指向中断处理程序的代码段。
IDT表的加载与测试
- 使用LIDT指令加载IDT表到IDTR寄存器,类似LGDT指令用于GDT表。
- 在QEMU中调试IDT表的加载过程,确认表首地址和大小。
- 通过QEMU的monitor window验证IDT表加载成功。
7、捕获除0异常
中断保护现场
为了简单期见,直接将所有的寄存器进行压栈保护,压入方法时,CPU会自动保存一部分,其余在中断程序中,通过push指令压入一部分
汇编中断处理函数的实现
- 中断处理函数必须用汇编语言编写,因为C语言编译的函数返回指令不符合要求。
- 中断处理函数的入口点需要全局声明,并在汇编文件中实现。
- 中断处理函数的一般结束指令是iret,这是x86架构上返回中断的指令。
8、解析异常栈信息
发生异常时,硬件会自动压入一部分数据到栈中,下图所示,根据是否有特权级变化,有两种不同类型的压栈方法。
使用pusha指令压入各种寄存器的值,通过push es等指令压入其它段寄存器的值
我们知道对于异常处理函数,压入栈的寄存器相当于传递给它的参数,难点在于压入栈中的寄存器数量太多。因此使用结构体exception_frame_t映射栈中这些寄存器的值,然后在栈中压入所有寄存器值存储的起始位置
9、利用宏异常处理代码
通过引宏的概念来减少代码的重负
- 定义异常处理宏,包含异常类型和号码参数。
- 宏根据参数值调用特定C处理函数。
- 压入错误码和异常号码,提供异常详细信息
10、处理其它类型的所有异常
11、初始化中断控制器
8259定时器中断:
- 8259用于管理中断,最大只支持对8个中断进行管理
- 外设的中断请求信号连接到IRQ?,然后再由8259进行处理,最后通过INT信号传递给处理器,产生中断请求信号。处理器在收到INT的请求信号后,跳转到对应的中断处理程序中执行。
- 硬件部分上,计算机内部已经配置完毕,使用两片8259级联,最多管理15种类型的中断
8259的工作模式较为复杂,本课程只做了非常简单的配置,不考虑中断嵌套、优先级等问题
12、中断的打开与关闭
中断控制受两部分控制
- 首先是8259中断控制器控制着外设的中断信号是否允许到达CPU,这项功能由其内部寄存器IMR控制。该控制器为8位,其中bit0控制该芯片的IRQ0,bit1控制IRQ1,依此类推
- cpu内部的EFLAGS标志寄存器中的IF位控制中CPU是否响应着任意来自8259的中断信号。如果为1,则允许,如果为0则禁止。
13、启动定时器并打开中断
8253是一颗带有3个内部计数器的定时器的芯片,用于为计算机提供相关的定时和计数功能,主要关心定时器/计数器
为实现对8253进行配置,可通过如下端口进行设置。
端口地址 名称
0x40 定时器0数据端口
0x41 定时器1数据端口
0x42 定时器2数据端口
0x43 模式和命令端口
本项目只需要一个可以周期性产生中断的定时器,无需考虑各种硬件方面的东西。因此,只需要将定时器设置成自动周期性触发中断即可