6.添加全局段描述符表

6.1保护模式

保护模式需要对内存段的性质和允许的操作给出定义,以实 现对特定内存段的访问检测和数据保护

80386中原先的 AX,BX,CX,DX,SI,DI,SP,BP从16位扩展(Extend)到了32位,并改名EAX,EBX, ECX,EDX,ESI,EDI,ESP,EBP,E就是Extend的意思。

8036保护模式下的分段

8036 虽然采用分段的方式进行寻址,但是 只是分了一个段

即段基址为0x00000000, 短长为0xFFFFFFFF(4GB)

32位的保护模式下,对一个内存段的描述需要8个字节 — 段描述符 (8字节 = 64位)

段描述符又分为:数据段描述符,指令段描述符和系统段描述符三种

image-20240510181329916

描述符表:在内存中存放在描述符的集合

intel 直接设置了一个48位的全局描述符表寄存器(GDTR)来保存描述符表的信息

image-20240510200652839

16位来表示表的长度,那么2的16次方就是65536字节,除以每一个描述符的8字 节,那么最多能创建8192个描述符

现代操作系统不在使用分段,而是直接使用分页技术

分页只是保护模式下的一种内存管理策略

6.2分段策略 - gdt.h

GRUB 在载入内核时候的一些状态

  1. CS 指向基地址为 0x00000000,限长为4G – 1的代码段描述符

  2. DS,SS,ES,FS 和 GS 指向基地址为0x00000000,限长为4G–1的数据段描述符

在内核中实现GDT 全局描述符表

include/gdt.h

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
#ifndef INCLUDE_GDT_H_
#define INCLUDE_GDT_H_

#include "types.h"

// 全局描述符类型 具体多少位 对应可以看上述的图 共8字节 64位
typedef
struct gdt_entry_t {
uint16_t limit_low; // 段界限 15~0
uint16_t base_low; // 段基地址 15~0
uint8_t base_middle; // 段基地址 23~16
uint8_t access; // 段存在位、描述符特权级、描述符类型、描述符子类别
uint8_t granularity; // 其他标志、段界限 19~16
uint8_t base_high; // 段基地址 31~24
} __attribute__((packed)) gdt_entry_t;

// GDTR 48位
typedef
struct gdt_ptr_t {
uint16_t limit; // 全局描述符表限长 16位
uint32_t base; // 全局描述符表 32位 基地址
} __attribute__((packed)) gdt_ptr_t;

// 初始化全局描述符表
void init_gdt();

// GDT 加载到 GDTR 的函数[汇编实现]
extern void gdt_flush(uint32_t);

#endif // INCLUDE_GDT_H_

__attribute__((packed))

是一个由GCC提供的特殊属性,用于指示编译器如何在内存中布局结构体或联合体的成员。使用这个属性可以确保编译器生成的数据结构不进行任何自动的内存对齐,而是严格按照成员声明的顺序将它们紧密地打包在一起。

函数实现

gdt/gdt.c

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
#include "gdt.h"
#include "string.h"

// 全局描述符表长度
#define GDT_LENGTH 5

// 全局描述符表定义
gdt_entry_t gdt_entries[GDT_LENGTH];

// GDTR
gdt_ptr_t gdt_ptr;

// 全局描述符表构造函数,根据下标构造 参数 GDT表中索引, 段基地址, 段的大小限制, 段的类型 特权级等,段的颗粒度。
static void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran);

// 声明内核栈地址
extern uint32_t stack;

// 初始化全局描述符表
void init_gdt()
{
// 全局描述符表界限 e.g. 从 0 开始,所以总长要 - 1
gdt_ptr.limit = sizeof(gdt_entry_t) * GDT_LENGTH - 1;
gdt_ptr.base = (uint32_t)&gdt_entries;

// 采用 Intel 平坦模型 5个 gdt
gdt_set_gate(0, 0, 0, 0, 0); // 按照 Intel 文档要求,第一个描述符必须全 0
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // 指令段
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // 数据段
gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // 用户模式代码段
gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // 用户模式数据段

// 加载全局描述符表地址到 GPTR 寄存器
gdt_flush((uint32_t)&gdt_ptr);
}

// 全局描述符表构造函数,根据下标构造
// 参数分别是 数组下标、基地址、限长、访问标志,其它访问标志
/* 结构体定义如下:
typedef struct
{
uint16_t limit_low; // 段界限 15~0
uint16_t base_low; // 段基地址 15~0
uint8_t base_middle; // 段基地址 23~16
uint8_t access; // 段存在位、描述符特权级、描述符类型、描述符子类别
uint8_t granularity; // 其他标志、段界限 19~16
uint8_t base_high; // 段基地址 31~24
} __attribute__((packed)) gdt_entry_t;
*/
static void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran)
{
gdt_entries[num].base_low = (base & 0xFFFF);
gdt_entries[num].base_middle = (base >> 16) & 0xFF;
gdt_entries[num].base_high = (base >> 24) & 0xFF;

gdt_entries[num].limit_low = (limit & 0xFFFF);
gdt_entries[num].granularity = (limit >> 16) & 0x0F;

gdt_entries[num].granularity |= gran & 0xF0;
gdt_entries[num].access = access;
}

GDT_LENGTH 5 — 为什么是5

在典型的x86保护模式操作系统中,这些段通常包括:

  1. 空描述符(Null Descriptor):
    • 按照Intel的规定,GDT的第一个条目必须是一个空描述符。这不是用于实际的内存段,但它有助于捕捉到无效的段选择器访问,因为任何尝试访问索引为0的描述符的操作都会导致异常。
  2. 内核代码段(Kernel Code Segment):
    • 用于操作系统内核代码的执行。这通常是特权级0的代码段,只能由内核访问。
  3. 内核数据段(Kernel Data Segment):
    • 用于操作系统内核数据。这也是特权级0的数据段。
  4. 用户代码段(User Code Segment):
    • 用于用户模式程序的代码执行。这是特权级3的代码段,允许用户级应用程序执行。
  5. 用户数据段(User Data Segment):
    • 用于用户模式程序的数据。这同样是特权级3的数据段。

定义这5个条目是为了确保操作系统能够正确区分内核模式和用户模式,同时处理用户程序和内核之间的权限切换。这种分隔是现代操作系统安全性和稳定性的关键,因为它防止了用户程序直接访问内核资源和其他敏感数据。

gdt/gdt_s.s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[GLOBAL gdt_flush]

gdt_flush:
mov eax, [esp+4] ; 参数存入 eax 寄存器
lgdt [eax] ; 加载到 GDTR [修改原先GRUB设置]

mov ax, 0x10 ; 加载我们的数据段描述符
mov ds, ax ; 更新所有可以更新的段寄存器
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; 远跳转,0x08是我们的代码段描述符
; 远跳目的是清空流水线并串行化处理器
.flush:
ret
  1. 获取参数并加载GDT
    • mov eax, [esp+4]: 这条指令从栈中获取函数参数。在C语言调用汇编时,参数通常通过栈传递。[esp+4] 表示取栈指针 esp 向上偏移4个字节的位置的值(因为 esp 指向的是返回地址),这里保存的是传递给 gdt_flush 函数的参数,即新GDT的地址。
    • lgdt [eax]: lgdt 是加载全局描述符表寄存器(GDTR)的指令。[eax] 表示使用 eax 寄存器中的地址来加载GDTR。这里的地址指向一个 gdt_ptr 结构,其中包含了GDT的长度和基址。
  2. 更新段寄存器
    • mov ax, 0x10: 将16进制值 0x10 移动到 ax 寄存器。这个值是数据段的选择子(selector),在GDT中的偏移量。这通常指向GDT中的第二个条目(首个条目是空描述符),这里定义为内核数据段。
    • mov ds, axmov es, axmov fs, axmov gs, axmov ss, ax: 这些指令将数据段选择子加载到所有数据段寄存器(ds, es, fs, gs, ss)。这样确保所有的段寄存器都指向正确的段描述符,对应新的GDT设置。
  3. 远跳转以更新代码段寄存器和清空流水线
    • jmp 0x08:.flush: 执行一个远跳转到同一代码段中的.flush标签。0x08 是代码段的选择子(在GDT中的位置),这里通常指向GDT的第一个代码段描述符。远跳转不仅跳转到指定的代码段,还强制CPU清空预取队列和流水线,并且实际上更新了CPU的内部结构,使代码段寄存器(cs)与新的GDT同步。
    • .flush:: 这是远跳转的目标位置,紧跟着的 ret 指令会返回到调用 gdt_flush 的代码中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在调用 gdt_flush 函数之前,调用者(可能是C语言的初始化代码)将参数(gdt_ptr 的地址)压入栈中,并调用 gdt_flush 函数。栈的结构大致如下:
+-----------------+ <- ESP (栈指针)
| 参数:&gdt_ptr |
+-----------------+
| 返回地址 |
+-----------------+
| 调用者的局部变量 |
+-----------------+
| ... |

// 当 gdt_flush 函数被调用时,CPU自动将返回地址(即调用 gdt_flush 之后的下一条指令的地址)压入栈中。此时,ESP 指向栈中的返回地址。
+-----------------+ <- ESP (新的栈指针位置)
| 返回地址 |
+-----------------+
| 参数:&gdt_ptr |
+-----------------+
| 调用者的局部变量 |
+-----------------+
| ... |

修改 entry.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "types.h"
#include "console.h"
#include "debug.h"
#include "gdt.h"

int kern_entry(){
init_debug();
init_gdt();

console_clear();
//console_write_color("Hello, OS kernel!\n", rc_black, rc_green);
//panic("test");

printk_color(rc_blue, rc_red, "Hello OS!!!\n");
return 0;
}

image-20240510212154383