XuSenfeng

个人站

复读了,更新随缘,有的文件不全或者图片缺失具体看我的笔记库(https://github.com/XuSenfeng/note)


x86保护模式下的编程

目录

x86保护模式下的编程

实现一个小型的操作系统

实模式=>BIOS中断=>保护模式=>GDT/LDT表

​ =>TSS任务状态

​ =>调用门(系统调用)

创建工程

  • Makefile文件
# 功能:工程目标创建的makefile文件
# 
# 创建时间:2022年8月31日
# 作者:李述铜
# 联系邮箱: 527676163@qq.com
# 相关信息:此工程为《从0写x86 Linux操作系统》的前置课程,用于帮助预先建立对32位x86体系结构的理解
# 课程请见:https://study.163.com/course/introduction.htm?courseId=1212765805&_trace_c_p_k2_=0bdf1e7edda543a8b9a0ad73b5100990

# 工具链前缀,如果是windows和mac,使用x86_64-elf-
# 如果是linux,使用x86_64-linux-gnu-
# 工具链前缀,如果是windows和mac,使用x86_64-elf-
# 如果是linux,使用x86_64-linux-gnu-
ifeq ($(LANG),)
	TOOL_PREFIX = x86_64-linux-gnu-
else
	TOOL_PREFIX = x86_64-elf-
endif

# GCC编译参数
CFLAGS = -g -c -O0 -m32 -fno-pie -fno-stack-protector -nostdlib -nostdinc

# 目标创建:涉及编译、链接、二进制转换、反汇编、写磁盘映像
all: source/os.c source/os.h source/start.S
	$(TOOL_PREFIX)gcc $(CFLAGS) source/start.S
	$(TOOL_PREFIX)gcc $(CFLAGS) source/os.c	
	$(TOOL_PREFIX)ld -m elf_i386 -Ttext=0x7c00 start.o os.o -o os.elf
	${TOOL_PREFIX}objcopy -O binary os.elf os.bin
	${TOOL_PREFIX}objdump -x -d -S  os.elf > os_dis.txt	
	${TOOL_PREFIX}readelf -a  os.elf > os_elf.txt	
	dd if=os.bin of=../image/disk.img conv=notrunc

# 清理
clean:
	rm -f *.elf *.o

首先进行编译, 之后进行链接文件, 对链接的文件转化为二进制, 二进制的文件最后反汇编, 还有对elf文件进行解析, dd命令会把二进制文件写入磁盘映像文件, 磁盘映像文件会被之后的qemu调用执行

这个命令的作用是将 os.bin 文件的内容写入到 ../image/disk.img 文件中,不截断(notrunc)原有的内容。

  • dd: 用于复制文件或转换文件格式的命令。
  • if=os.bin: 指定输入文件为 os.bin
  • of=../image/disk.img: 指定输出文件为 ../image/disk.img,即磁盘映像文件。
  • conv=notrunc: 指定不截断原有的内容,即在磁盘映像文件中保留原有的内容。
{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0", 
    "tasks": [
       {
            "label": "启动qemu",
            "type": "shell",
            "command": "bash ${workspaceRoot}/script/qemu-debug-osx.sh",
            "windows": {
                // windows下特殊一些
                "command": "${workspaceRoot}/script/qemu-debug-win.bat",
            },
            "linux":{
                "command": "bash ${workspaceRoot}/script/qemu-debug-linux.sh",
            },
            "options": {
                "cwd": "${workspaceRoot}/../image/"
            }
        },
    ]
}

在这里设置了启动qemu的命令

  • c文件
/**
 * 功能:32位代码,完成多任务的运行
 *
 *创建时间:2022年8月31日
 *作者:李述铜
 *联系邮箱: 527676163@qq.com
 *相关信息:此工程为《从0写x86 Linux操作系统》的前置课程,用于帮助预先建立对32位x86体系结构的理解。整体代码量不到200行(不算注释)
 *课程请见:https://study.163.com/course/introduction.htm?courseId=1212765805&_trace_c_p_k2_=0bdf1e7edda543a8b9a0ad73b5100990
 */
#include "os.h"
  • h文件
/**
 * 功能:公共头文件
 *
 *创建时间:2022年8月31日
 *作者:李述铜
 *联系邮箱: 527676163@qq.com
 *相关信息:此工程为《从0写x86 Linux操作系统》的前置课程,用于帮助预先建立对32位x86体系结构的理解。整体代码量不到200行(不算注释)
 *课程请见:https://study.163.com/course/introduction.htm?courseId=1212765805&_trace_c_p_k2_=0bdf1e7edda543a8b9a0ad73b5100990
 */
#ifndef OS_H
#define OS_H

#endif // OS_H
  • 汇编
/**
 * 功能:16位与32位的启动混合代码
 *
 *创建时间:2022年8月31日
 *作者:李述铜
 *联系邮箱: 527676163@qq.com
 *相关信息:此工程为《从0写x86 Linux操作系统》的前置课程,用于帮助预先建立对32位x86体系结构的理解。整体代码量不到200行(不算注释)
 *课程请见:https://study.163.com/course/introduction.htm?courseId=1212765805&_trace_c_p_k2_=0bdf1e7edda543a8b9a0ad73b5100990
 */
	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问
	.global _start

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
_start: 
	jmp .
  • qemu
start qemu-system-i386 -m 128M -s -S  -drive file=disk.img,index=0,media=disk,format=raw
  • qemu-system-i386: 这是 QEMU 模拟器的命令,用于启动一个 x86 架构的虚拟机。
  • -m 128M: 指定虚拟机的内存大小为 128MB。
  • -s: 启用 GDB 调试,允许在虚拟机运行时进行调试。
  • -S: 在启动时停止 CPU,等待调试器连接。这样可以在虚拟机启动前设置调试器。
  • -drive: 指定虚拟机的磁盘映像文件。
    • file=disk.img: 指定磁盘映像文件的名称为 disk.img
    • index=0: 指定磁盘的索引为 0。
    • media=disk: 指定磁盘的介质类型为磁盘。
    • format=raw: 指定磁盘映像文件的格式为原始格式,即未经过任何压缩或编码的二进制数据。

总之,这个命令的作用是启动一个 x86 架构的虚拟机,并加载一个名为 disk.img 的磁盘映像文件,同时启用 GDB 调试功能。

启动方式

首先使用的是16位的模式, 之后会启用BIOS, BIOS进行自检, 运行引导代码, 之后进入操作系统

前三部分是不能控制的, 但是后面的引导代码等是可以自己写的

启动流程

这一堂课使用了一块硬盘, disk.img硬盘, 一般的情况下硬盘是按照数据块512字节进行的, 这是读取的最小单位

还有一块自己的内存, 可以在任意位置进行读取

Screenshot_20230908_100954_com.xiaoe.client

BIOS会检查硬盘的第一个扇区的最后两个字节如果是上面的两个数据, 就会进行拷贝, 之后进行执行, 拷贝到0x7c00位置, os.bin文件会通过dd命令写入disk.img文件的开头

image-20230908102628762

没有加载有效字节的结果

	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问
	.global _start

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
_start: 
	jmp .

	.org 0x1fe				//十进制是510,在这里跳转到对应的位置
	.byte 0x55, 0xaa

image-20230908103332365

识别成功

image-20230908103606213

指令执行的位置到达对应的位置

x86处理器编程模型

内核寄存器

  • 通用寄存器

Screenshot_20230908_104010_com.xiaoe.client

可以利用不同的名字对各个寄存器的不同位置进行操作

  • 段寄存器

Screenshot_20230908_104329_com.xiaoe.client

早期的十六位CPU访问的范围比较小, 所以设置一个起始位置, 之后在上面进行叠加访问

Screenshot_20230908_104618_com.xiaoe.client

段寄存器的值会进行左移四位, 用来扩大储存数据的位置

在这时候把这些全部设置为0, 为了简化操作, 之后直接进行访问就可以了, 这里只设置上面的几个寄存器DS, SS, ES(附加扩展段)

写操作时,会写入96位,其中源操作数的16位写入到段寄存器的段选择子部分,另外80位会根据段选择子从GDT表(全局描述表)中获取.

  • EIP

指定当前的程序运行到的位置

  • 状态寄存器EFLAGS

有一些标志位

	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问
	.global _start

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
_start: 
	jmp $0, $offset			//这个是用来设置cs寄存器的, 可以不加,因为会自动跳转
offset:
	mov $0, %ax				//不能直接对段寄存器进行写入,通过ax进行中转
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs

	jmp .

	.org 0x1fe				//十进制是510,在这里跳转到对应的位置
	.byte 0x55, 0xaa

image-20230908123951948

  • 配置栈空间

设置为栈空间的末尾地址

	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问
	.global _start

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
_start:

	mov $0, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp		//在这里设置栈顶指针
	jmp .

	.org 0x1fe				//十进制是510,在这里跳转到对应的位置
	.byte 0x55, 0xaa

加载自己的剩余程序

由于第一块的内存太小, 不够存储所有的代码, 所以需要加载后面的程序

BIOS会提供一些中断, 这里使用的中断是INT 13

入口参数:

  • ah= 2表示读扇区、3表示写扇区
  • al=读取/写入的扇区数
  • ch=磁道号
  • cl=扇区号
  • dh=磁头号(对于软盘即面号,对软盘—个面用一个磁头来读写)
  • dl=驱动器号软驱从0开始,0:软驱A、1:软驱B。硬盘从80H开始,80H:硬盘C、81H:硬盘D
  • es:bx指向接收从扇区读入数据的内存区/指向将写入磁盘的数据

之前将es设置为0, 所以这里需要设置bx的保存的位置

读取失败的话会把EFLAGS里面的CF设置为1

_start:

	mov $0, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp

read_self_all:
	//首先设置读取到的位置
	mov $0x7E00, %bx
	//在这里设置开始的扇区数
	mov $0x2, %cx
	//在这里设置的是读取的数量,以及是使用的读取模式
	mov $0x240, %ax
	//在这里设置读取的是C盘
	mov $0x80, %dx
	..调用中断
	int $0x13
	//读取失败再次读取
	jc read_self_all


	jmp .
  • 测试
	jmp .

	//以下是标志位
	.org 0x1fe				//十进制是510,在这里跳转到对应的位置
	.byte 0x55, 0xaa

	.fill 64*1024, 1, 0x25

在后面填进去一些数据

image-20230908193552168

image-20230908193533914

img文件以及运行时候内存的文件, 在运行的时候使用这一个命令可以查看对应位置的内存

进入保护模式

在进入系统的时候进入的实际上是16位的CPU工作模式(实模式), 之后需要进入32位的模式

通过段寄存器记录一个基地址, 之后通过偏移量进行访问数据, 这一种模式是比较危险的, 可以随意设置段寄存器的位置, 但是进入保护模式之后对于存储的访问会进行一些检查, 还会检查是否超过边界, 还会有中断向量表以及多任务运行的功能

CPU需要一些设置, 首先需要一个GDP表, 还需要修改一个寄存器, 还需要修改段寄存器的设置, 这时候存放的是索引, 这个表的位置保存在GDTR寄存器里面, 表里面记录有大小, 位置, 权限等

每一个字段是8字节

image-20230911165013102

Base: 指明段的地址

limit: 段的长度

S: 0的时候是系统段, TSS/LDT等, 1的时候表示这一段是数据段或者代码段

DPL: 段的访问权限, 0-3

P: 这一个段是否有效

D/B: 代码段的时候制定了操作数和地址是32位还是16位, 栈的时候指定了栈是32位还是16位

G: 指定limit的单位是byte还是4KB

L: 64位下面使用

AVL: 保留

type: 段的类型

image-20230911170239279

  • 第一个表段必须为0
  • 进入模式的时候需要指定一个代码段和一个数据段

0xcf9a: 1100 1111 1001 1010

typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
//这一个表需要进行八字节对齐
struct {uint16_t limit_l, base_l, basehl_attr, base_limit;}gdt_table[256] __attribute__((aligned(8))) = {
    // 0x00cf9a000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,32位代码段(非一致代码段),界限4G,
    [KERNEL_CODE_SEG / 8] = {0xffff, 0x0000, 0x9a00, 0x00cf},
    // 0x00cf93000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,数据段,界限4G,可读写
    [KERNEL_DATA_SEG/ 8] = {0xffff, 0x0000, 0x9200, 0x00cf},
};
  • 设置GDT的位置之后需要将CR0的最低位PE设置为1
  • 之后需要进行一次跳转, 跳转位置是选择子+偏移量
	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问
	.global _start

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
_start:

	mov $0, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp

read_self_all:
	//首先设置读取到的位置
	mov $_start_32, %bx
	//在这里设置开始的扇区数
	mov $0x2, %cx
	//在这里设置的是读取的数量,以及是使用的读取模式
	mov $0x240, %ax
	//在这里设置读取的是C盘
	mov $0x80, %dx
	//调用中断
	int $0x13
	//读取失败再次读取
	jc read_self_all
	//进入保护模式
	//关中断
	cli
	//加载新的GDT表
	lgdt gdt_desc
	//设置CR0的0位, 操作的时候使用16位的操作寄存器
	mov $1, %eax
	lmsw %ax
	//跳转到内核代码段,进入32位模式,第二个数字是偏移量,也就是C语言程序被复制到的位置
	jmp $(KERNEL_CODE_SEG),$_start_32
	jmp .

	//以下是标志位
	.org 0x1fe				//十进制是510,在这里跳转到对应的位置
	.byte 0x55, 0xaa
	
	//标记下面是32位, 以及是代码段
	.code32
	.text
_start_32:
	//在这里设置段地址
	mov $KERNEL_DATA_SEG, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp
	jmp .

//这里记录的是GDT表的数据,包括32位的基地址以及16位的大小界限
gdt_desc:
	//界限+地址
	.word (256*8)-1
	.long gdt_table
  • 调试

image-20230911213220046

image-20230911213417535

使用命令info registers

image-20230911213826872

分页机制

在不使用分页机制的时候, 我们看到的是物理内存, 物理内存有多大, 我们就可以使用多大的内存

使用内存分页机制, 我们就可以扩充访问的地址范围, 也可以实现权限的细分, 实际上就是实现虚拟内存, 将地址进行映射, 看到的内存更大了, 但是实际上可以使用的内存的大小还是不变的

访问过程: 根据段寄存器找到对应的记录的GDT表, 之后根据表找到自己的使用的内存, 加上偏移量之后就是实际的地址, 这一个地址会通过分页机制里面的页表, 页表的地址放在CR3的寄存器里面

image-20230912091325312

会根据传过来的数据的地址分段之后进行访问不同的页表, 获得一个4KB的空间的地址, 最后通过偏移量进行实际的访问

实际的实现

第一级映射(页目录表PDE)有两种的格式, 一种是4MB的映射, 一种是4KB的映射使用4MB模式的时候, 就不需要二级页表了, 只有一个表, 最后可以使用的内存实际上是4MB, 使用4KB模式的时候会使用两级页表, 最后实际控制的内存大小是4GB

第二级映射(页表PTE)

需要在打开页表之前实现映射, 否则CPU会找不到对应的内存, 直接映射到0地址的位置

//这个表是否有效
#define PDE_P                   (1<<0)
//是否可写
#define PDE_W                   (1<<1)
//是否可以被低权限访问
#define PDE_U                   (1<<2)
//设置使用的模式
#define PDE_PS                  (1<<7)

//定义一个页表的结构体,需要设置低0的表项
uint32_t pg_dir[1024] __attribute__((aligned(4096))) = {
    [0] = (0) | PDE_P |  PDE_W | PDE_U | PDE_PS;
};
_start_32:
	//在这里设置段地址
	mov $KERNEL_DATA_SEG, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp

	//打开页表
	mov $pg_dir, %eax
	mov %eax, %cr3
	//CR4里面有一个位控制是否允许这一个模式 
	mov %cr4, %eax
	orl $(1<<4), %eax
	mov %eax, %cr4
	//还需要控制PR0最高位w为1
	mov %cr0, %eax
	orl $(1<<31), %eax
	mov %eax, %cr0

	jmp .

image-20230912100752988

image-20230912100916065

分页打开前后, 权限是用户可以读写

//这个表是否有效
#define PDE_P                   (1<<0)
//是否可写
#define PDE_W                   (1<<1)
//是否可以被低权限访问
#define PDE_U                   (1<<2)
//设置使用的模式
#define PDE_PS                  (1<<7)
//新建另一个映射的地址
#define MAG_ADDR                0x80000000
//使用二级表进行控制内存测试, 这里是实际上的地址
uint8_t map_phy_buffer[4096] __attribute__((aligned(4096))) = {0x36};
//创建一个二级表项,随便给一个值,在后面会进行设置,随便初始化一个值连接器会把其他的位置设置为0,否则会为随机的
static uint32_t page_table[1024] __attribute__((aligned(4096))) = {PDE_U};

//定义一个页表的结构体,需要设置低0的表项
uint32_t pg_dir[1024] __attribute__((aligned(4096))) = {
    [0] = (0) | PDE_P |  PDE_W | PDE_U | PDE_PS,
};

void os_init(void){
    //设置一级表,使用的是表的高10位,这里会找到想要的虚拟地址所在的位置,设置为二级表的位置
    pg_dir[MAG_ADDR>>22] = (uint32_t)page_table | PDE_P | PDE_W | PDE_U;
    //初始化表的二级,这里是实际的地址,之后需要设置对应的位置,这里会设置二级表指向的是上面的数组
    page_table[(MAG_ADDR>>12)&0x3ff] = (uint32_t)map_phy_buffer | PDE_P | PDE_W | PDE_U;

}
_start_32:
	//在这里设置段地址
	mov $KERNEL_DATA_SEG, %ax
	mov %ax, %ds
	mov %ax, %es
	mov %ax, %ss
	mov %ax, %gs
	mov %ax, %fs
	mov $_start, %esp
	//在这里调用设置4KB的分页表
	call os_init
	//打开页表
	mov $pg_dir, %eax
	mov %eax, %cr3
	//CR4里面有一个位控制是否允许这一个模式 
	mov %cr4, %eax
	orl $(1<<4), %eax
	mov %eax, %cr4
	//还需要控制PR0最高位w为1
	mov %cr0, %eax
	orl $(1<<31), %eax
	mov %eax, %cr0

	jmp .

image-20230912144435370

image-20230912144527920

在修改之后发现两个位置是同步的, 可以直接操控第二个映射地址或者采用第一个映射的地址

总结

也就是说,在没有开启分页机制时,由程序员给出的逻辑地址,需要先通过分段机制转换成物理地址。但在开启分页机制后,逻辑地址仍然要先通过分段机制进行转换,只不过转换后不再是最终的物理地址,而是线性地址,然后再通过一次分页机制转换,得到最终的物理地址。

开启定时器

8253定时器

一般情况下是内置在CPU里面的, 不需要了解所有的, 因为现在已经内置在CPU里面很多并不需要了解

image-20230913092705738

定时器连接在IRQ0, 一共有三个定时器,其他的中断进行屏蔽

IDT表

IDTR寄存器进行控制, 中断发生的时候会参考这一个表进行进行处理

定时器的中断会使用0x20位置的中断

image-20230913093734029

Segment Selector 这里记录是段选择子, 指向GDT表的某一个段, 这里应该是代码段

Offset 偏移, 具体的处理函数所在的位置

D 表示是否是32位

P 存在的标志位

实际的实现

对于intel架构的CPU在操控外设的寄存器的时候使用命令out, ARM的时候直接操控寄存器映射

中断在使用的时候需要设置具体的表项,前面的表项已经被CPU使用了


	#include "os.h"

	// 声明本地以下符号是全局的,在其它源文件中可以访问,在这里声明中断处理函数
	.global _start, timer_int

	// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
  	.code16

	// 以下是代码区
 	.text
 	
	...
	
	//关中断
	cli
	//加载新的GDT表
	lgdt gdt_desc
	//加载中断向量表
	lidt idt_desc

	...
	
_start_32:

	...
	//在这里跳转到C文件初始化中断以及初始化段
	call os_init
	//打开页表
	mov $pg_dir, %eax
	mov %eax, %cr3
	//CR4里面有一个位控制是否允许这一个模式 
	mov %cr4, %eax
	orl $(1<<4), %eax
	mov %eax, %cr4
	//还需要控制PR0最高位w为1
	mov %cr0, %eax
	orl $(1<<31), %eax
	mov %eax, %cr0

	//打开中断
	sti
	jmp .
//中断处理函数
timer_int:

	//对寄存器进行保护
	push %ds
	pusha

	//清除中断
	mov $0x20, %al
	outb %al, $0x20
	
	popa
	pop %ds
	
	iret

//这里记录的是GDT表的数据,包括32位的基地址以及16位的大小界限
gdt_desc:
	//界限+地址
	.word (256*8)-1
	.long gdt_table
//描述中断的数据
idt_desc:
	//界限+地址
	.word (256*8)-1
	.long idt_table
//对汇编指令进行一个封装
void outb(uint8_t data,uint16_t port){
    //这里传入两个数据,第一个数据是data,第二个数据是port,之后进行数据的关联,"d"会识别为%dx,之后会把port放进去
    __asm__ __volatile__("outb %[v], %[p]"::[p]"d"(port), [v]"a"(data));
}

//在汇编文件中实现的中断处理函数
void timer_int(void);
//初始化一个页表,并在之后设置为0x80000000映射到这一个数组的位置
void os_init(void){
    //初始化定时器
    //初始化中断控制器
    outb(0x11, 0x20);
    outb(0x11, 0xA0);
    //设置主片的中断起始位置
    outb(0x20, 0x21);
    //设置从片的中断起始位置
    outb(0x28, 0xa1);
    //设置从片连接主片的位置
    outb((1<<2), 0x21);
    //设置从片连接主片使用的引脚
    outb(2, 0xa1);
    //设置连接的模式
    outb(0x1,0x21);
    outb(0x1,0xa1);
    //设置中断的屏蔽
    outb(0xfe,0x21);
    outb(0xff,0xa1);
    //配置外部时钟源,是一个16位的计数器,减到0的时候会产生中断,这里计算每秒产生100次中断大概需要的数值
    int tmo = 1193180 / 100;
    //写入数值
    //设置使用的时钟以及自动加载
    outb(0x36, 0x43);
    //中断频率的设置
    outb((uint8_t)tmo, 0x40);
    outb(tmo>>8, 0x40);
    //记录中断处理函数的地址
    idt_table[0x20].offset_l = (uint32_t)timer_int & 0xffff;
    idt_table[0x20].offset_h = (uint32_t)timer_int >> 16;
    idt_table[0x20].selector = KERNEL_CODE_SEG;
    //设置为中断门,32位模式
    idt_table[0x20].attr = 0x8e00;

    //设置一级表,使用的是表的高10位
    pg_dir[MAG_ADDR>>22] = (uint32_t)page_table | PDE_P | PDE_W | PDE_U;
    //初始化表的二级,这里是实际的地址,之后需要设置对应的位置
    page_table[(MAG_ADDR>>12)&0x3ff] = (uint32_t)map_phy_buffer | PDE_P | PDE_W | PDE_U;
}

  • 任务门(task gate)

当中断信号发生时,必须取代当前进程的那个进程的TSS选择符存放在任务门中。

  • 中断门(interruptgate)

包含段选择符和中断或异常处理程序的段内偏移量.当控制权转移到一个适当的段 时,处理器 清IF标志,从而关闭将来会发生的可屏蔽中断., 主要是为了INTR中断(计算机系统中的一种机制,用于在CPU执行程序时,暂停当前程序的执行,转而执行其他程序或处理器的中断请求)。

  • 陷阱门(Trap gate)

与中断门相似,只是控制权传递到一个适当的段时处理器不修改IF标志.所以依旧会产生INTR中断

总结

在使用分页以后在进行访问内存的时候, 首先会进行分页表找到实际的物理地址, 之后在根据GDT表对实际的物理地址权限等进行分类, 最后在进行操作

切换到低特权模式

image.png

最高等级一般是操作系统, 可以访问所有的资源Level 0, 应用一般使用最低等级

只允许访问相同等级或者低权限的代码或者数据

用户级不允许执行一些命令, 以及不允许访问一些区域, 方便操作系统对其进行检测, 杀掉或者进行其他处理

CPU会执行CS指针指向的代码段, CS的最低两位CPL代表的是访问的时候的特权等级, GDT表里面的DPL设置的是这一个段的访问权限, 其他的段寄存器最低位存放的是RPL进行特权级的检查

设计一段进程

进程不可以使用内核段的数据段以及数据段

struct {uint16_t limit_l, base_l, basehl_attr, base_limit;}gdt_table[256] __attribute__((aligned(8))) = {
    // 0x00cf9a000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,32位代码段(非一致代码段),界限4G,
    [KERNEL_CODE_SEG / 8] = {0xffff, 0x0000, 0x9a00, 0x00cf},
    // 0x00cf93000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,数据段,界限4G,可读写
    [KERNEL_DATA_SEG/ 8] = {0xffff, 0x0000, 0x9200, 0x00cf},
    //这里是进程使用的段, 这里使用的是重合的模式, 设置优先级为3
    [APP_CODE_SEG /8] = {0xffff, 0x0000, 0xfa00, 0x00cf},
    [APP_DATA_SEG /8] = {0xffff, 0x0000, 0xf300, 0x00cf},
};
  • 实际的特权切换

在切换的时候不能使用jmp指令, 而是应该使用iret命令从中断返回的指令, 需要对栈进行配置

image.png

这里相当于从一个中断函数进行返回, 在进入中断的时候会自动保存上面右侧显示的寄存器内容, SS栈的段寄存器, ESP栈的指针, CS代码段, EIP指令对应的地址, 返回的时候会自动设置这些值

image-20230920130514065

这设置设个的时候主要是需要设置中断标志位为使能中断,设置为0x202

	//打开中断, 由于在进入任务的时候会设置IF位, 所以在这里的时候不再进行设置
	//sti
	
	//设置进入低特权级时候的所需要的栈
	push $APP_DATA_SEG
	//设置栈的指针
	push $0
	//设置EFLAGS, 主要是设置中断的状态
	push $0x202
	//这是代码段
	push $APP_CODE_SEG
	push $task_0_entry

	//在这里进入低特权级
	iret
	jmp .

//进程0
task_0_entry:
	jmp .

这时候会出现异常, 这是因为CPU在出战的时候会进行检查, 这时候发现代码段数据段的最低位没有设置为对应的权限

// 各选择子
#define KERNEL_CODE_SEG         (1 * 8)
#define KERNEL_DATA_SEG         (2 * 8)
#define APP_CODE_SEG            ((3 * 8) | 3)
#define APP_DATA_SEG            ((4 * 8) | 3)

img

进入特权级3的模式

  • 之后需要给这一个特权级的任务定义一个数组作为栈

image-20230920230330223

最好先把中断关掉, 否则会死机

实现任务切换

x86实现了任务切换的硬件上的支持, 只需要给每一个任务一个TSS就可以方便的使用一条指令进行任务切换

image.png

这里面存放的是当前任务的状态, CPU会自动把寄存器的状态存放在这里面, 这里面的SS, ESP等有多个是给不同特权级的时候使用不同的段

image-20230920232807856

在产生中断的时候会从特权级0的位置找到栈, 之后执行中断相关的内容

image-20230921083651573

需要在GDT里面使用两个段来记录两个任务的TSS, 通过GDT的描述符进行区分

image.png

Base: 起始地址

Segment Limit: 界限-1

DPL: 段的访问权限, 0-3

P: 这一个段是否有效

G: 指定limit的单位是byte还是4KB

AVL: 保留

type: 段的类型

B: 忙标志

//这一个表需要进行八字节对齐
struct {uint16_t limit_l, base_l, basehl_attr, base_limit;}gdt_table[256] __attribute__((aligned(8))) = {
    // 0x00cf9a000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,32位代码段(非一致代码段),界限4G,
    [KERNEL_CODE_SEG / 8] = {0xffff, 0x0000, 0x9a00, 0x00cf},
    // 0x00cf93000000ffff - 从0地址开始,P存在,DPL=0,Type=非系统段,数据段,界限4G,可读写
    [KERNEL_DATA_SEG/ 8] = {0xffff, 0x0000, 0x9200, 0x00cf},
    //这里是进程使用的段, 这里使用的是重合的模式, 设置优先级为3, 设置为可执行可读
    [APP_CODE_SEG /8] = {0xffff, 0x0000, 0xfa00, 0x00cf},
    //设置为可读可写可访问
    [APP_DATA_SEG /8] = {0xffff, 0x0000, 0xf300, 0x00cf},
    //TSS表, 由于直接使用一个数组作为TSS会导致报错,这里基地址初始化为0
    [TASK0_TSS_SEG /8] = {0x68, 0, 0xe900, 0},
    [TASK1_TSS_SEG /8] = {0x68, 0, 0xe900, 0},
};
....
//定义一个TSS结构
//任务切换的时候栈之类的寄存器不会保存, 需要初始化设置
uint32_t task0_tss[] = {
    // prelink, esp0, ss0, esp1, ss1, esp2, ss2
    0,  (uint32_t)task0_dpl0_stack + 4*1024, KERNEL_DATA_SEG , /* 后边不用使用 */ 0x0, 0x0, 0x0, 0x0,
    // cr3, eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi,
    (uint32_t)pg_dir,  (uint32_t)task_0/*入口地址*/, 0x202, 0xa, 0xc, 0xd, 0xb, (uint32_t)task0_dpl3_stack + 4*1024/* 栈 */, 0x1, 0x2, 0x3,
    // es, cs, ss, ds, fs, gs, ldt, iomap
    APP_DATA_SEG, APP_CODE_SEG, APP_DATA_SEG, APP_DATA_SEG, APP_DATA_SEG, APP_DATA_SEG, 0x0, 0x0,
};
//设置要切换的任务的栈以及任务的入口
uint32_t task1_tss[] = {
    // prelink, esp0, ss0, esp1, ss1, esp2, ss2
    0,  (uint32_t)task1_dpl0_stack + 4*1024, KERNEL_DATA_SEG , /* 后边不用使用 */ 0x0, 0x0, 0x0, 0x0,
    // cr3, eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi,
    (uint32_t)pg_dir,  (uint32_t)task_1/*入口地址*/, 0x202, 0xa, 0xc, 0xd, 0xb, (uint32_t)task1_dpl3_stack + 4*1024/* 栈 */, 0x1, 0x2, 0x3,
    // es, cs, ss, ds, fs, gs, ldt, iomap
    APP_DATA_SEG, APP_CODE_SEG, APP_DATA_SEG, APP_DATA_SEG, APP_DATA_SEG, APP_DATA_SEG, 0x0, 0x0,
};

.....

    gdt_table[TASK0_TSS_SEG / 8].base_l = (uint16_t)(uint32_t)task0_tss;
    gdt_table[TASK1_TSS_SEG / 8].base_l = (uint16_t)(uint32_t)task1_tss;
  • 使用TR寄存器保存当前的任务的TSS对应的GDT位置
	//告诉CPU正在运行的任务
	mov $TASK0_TSS_SEG, %ax
	ltr %ax
//中断处理函数
timer_int:

	//对寄存器进行保护
	push %ds
	pusha

	//清除中断
	mov $0x20, %al
	outb %al, $0x20
	//配置使用的代码段
	mov $KERNEL_DATA_SEG, %ax
	mov %ax, %ds
	//调用切换任务的函数
	call task_sched

	popa
	pop %ds
	
	iret
void task_sched(void){
    static int task_tss = TASK0_TSS_SEG;
    
    task_tss = (task_tss == TASK0_TSS_SEG) ? TASK1_TSS_SEG : TASK0_TSS_SEG;
    uint32_t addr[] = {0, task_tss};
    __asm__ __volatile__("ljmpl *(%[a])"::[a]"r"(addr));
}

image-20230921100950824

跳转之前的任务1

image-20230921101039232

image-20230921101421193

跳转之后, 有一些寄存器会发生自动的更改, 这时候的段寄存器发生了变化, 由于是在中断中发生的

image-20230921101010695

这是任务1没有开始执行的时候的数据

image-20230921101832319

进入任务1以后会写入寄存器里面

增加系统调用

允许应用调用操作系统的一些函数, 主要是由于权限, 需要在特区级下面运行一些操作

页表相关设置的时候有一个设置是PDE_U位, 这时候用户就可以访问这一段地址, 否则就是需要系统操作级来进行操作

image.png

这里填写的是选择子, 指向一个代码段, 也是注册在GDT表里面

Offset in Segment: 指的是偏移量, 实际指向的就是要运行的函数

Param Count这个是参数的数量

//这里是系统调用,首先不初始化任务的函数地址, 之后是代码段, 权限设置为3, 使用三个参数
[SYSCALL_SEG / 8] = {0x0000, KERNEL_CODE_SEG, 0xec03, 0},
void task_0(void)
{
    uint8_t color = 0;

    unsigned short *dest = (unsigned short *)0xb8000;
    dest [0] = 'a' | 0x3500;

    for(;;){
        color++;
    }
}

显示一个字符

image-20230921161254909

有80列25行

image-20230921165941136

会按照之前设置的参数, 会自动取出来三个参数

void sys_show(char *str, char color)
{
    uint32_t addr[] = {0, SYSCALL_SEG};

    __asm__ __volatile__("push %[color];push %[str];push %[id];lcalll *(%[a])"::
                    [a]"r"(addr), [color]"m"(color), [str]"m"(str), [id]"r"(2));
}

传入使用的三个参数, 之后跳转到对应的GDT对应的位置

    //设置系统调用函数的地址
    gdt_table[SYSCALL_SEG / 8].limit_l = (uint16_t)(uint32_t)syscall_handler;

这个函数是在汇编文件里面实现的

之后再由汇编到C的时候传递参数使用的栈

syscall_handler:
	//对寄存器进行保护
	push %ds
	pusha
	//使用内核数据段
	mov $KERNEL_DATA_SEG, %ax
	mov %ax, %ds
	//获取传进来的参数, 之后再次入栈
	mov %esp, %ebp
	push 13*4(%ebp)
	push 12*4(%ebp)
	push 11*4(%ebp)

	call do_syscall
	add $(3*4), %esp
	popa
	pop %ds
	retf $(3*4)
//这是系统调用在高权限的时候执行的函数
void do_syscall(int func, char * str, char color)
{
    static int row=1;
    if(func==2)
    {
        unsigned short *dest = (unsigned short *)0xb8000 + 80*row;
        while(*str){
            *dest ++ = *str ++ | (color<<8);
        }
        row = (row>=25)?0:row+1;
        for(int i=0;i<0xffffff;i++);
    }
    
}
//任务1
void task_0(void)
{
    char * str = "task1 a:1234";
    uint8_t color = 0;

    for(;;){
     	//在这里可以调用系统接口
        sys_show(str, color++);
    }
}

LDT

GDT: Global Descriptor Table

LDT: Local Descriptor Table

GDT可以被多个进程使用, LDT是每一个进程都有属于自己的一个表, 这会记录在TSS和GDT里面, TSS里面记录的是GDT里面的位置

  • LDT的作用

之前使用的时候直接把段设置为同一个代码段, 所以进程空间被两个进程同时使用, 相互之间的数据可以被看到

image-20230922142341114

CS和DS使用相同的值但是访问的内存的位置是不同的

image-20230922142400371

使用LDTR保存当前使用的LDT表

CPU会根据段寄存器的第2位来选择从GDT还是LDT获取信息, 1的时候是LDT, 0和1位设置的是权限

//这里是任务的LDT的配置
#define TASK_CODE_SEG           (0 * 8 | 0x4 | 3)
#define TASK_DATA_SEG           (1 * 8 | 0x4 | 3)
	//打开中断, 由于在进入任务的时候会设置IF位, 所以在这里的时候不再进行设置
	//sti
	
	//告诉CPU正在运行的任务
	mov $TASK0_TSS_SEG, %ax
	ltr %ax

	//设置任务的LDT
	mov $TASK0_LDT_SEG, %ax
	lldt %ax
	//设置进入低特权级时候的所需要的栈, 这时候在使用的就是LDT的位置了
	push $TASK_DATA_SEG
	//设置栈的指针
	push $task0_dpl3_stack + 1024*4
	//设置EFLAGS, 主要是设置中断的状态
	push $0x202
	//这是代码段
	push $TASK_CODE_SEG
	push $task_0_entry

	//在这里进入低特权级
	iret
	jmp .
//定义一个TSS结构, 这个是任务一的表
uint32_t task0_tss[] = {
    // prelink, esp0, ss0, esp1, ss1, esp2, ss2
    0,  (uint32_t)task0_dpl0_stack + 4*1024, KERNEL_DATA_SEG , /* 后边不用使用 */ 0x0, 0x0, 0x0, 0x0,
    // cr3, eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi,
    (uint32_t)pg_dir,  (uint32_t)task_0/*入口地址*/, 0x202, 0xa, 0xc, 0xd, 0xb, (uint32_t)task0_dpl3_stack + 4*1024/* 栈 */, 0x1, 0x2, 0x3,
    // es, cs, ss, ds, fs, gs, ldt, iomap
    TASK_DATA_SEG, TASK_CODE_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK0_LDT_SEG, 0x0,
};
//这个是任务二的表
uint32_t task1_tss[] = {
    // prelink, esp0, ss0, esp1, ss1, esp2, ss2
    0,  (uint32_t)task1_dpl0_stack + 4*1024, KERNEL_DATA_SEG , /* 后边不用使用 */ 0x0, 0x0, 0x0, 0x0,
    // cr3, eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi,
    (uint32_t)pg_dir,  (uint32_t)task_1/*入口地址*/, 0x202, 0xa, 0xc, 0xd, 0xb, (uint32_t)task1_dpl3_stack + 4*1024/* 栈 */, 0x1, 0x2, 0x3,
    // es, cs, ss, ds, fs, gs, ldt, iomap
    TASK_DATA_SEG, TASK_CODE_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK_DATA_SEG, TASK1_LDT_SEG, 0x0,
};
    //设置LDT的起始位置
    gdt_table[TASK0_LDT_SEG / 8].base_l = (uint16_t)(uint32_t)task0_ldt_table;
    gdt_table[TASK1_LDT_SEG / 8].base_l = (uint16_t)(uint32_t)task1_ldt_table;

总结

  • 首先BIOS会把磁盘第一部分加载到0x7c00的位置,加载512字节, 之后在这一段运行之后把其他的应用加载到内存
  • 之后了解了通用寄存器和段寄存器, 段寄存器用于内存的访问, 还有EIP和EFLAG寄存器
  • 之后使用了一些数据结构, GDT: 指示某一个内存区域的作用, LDT: 记录进程自己的区域, TSS: 进程的运行状态保存的位置, IDT: 中断向量表
  • 几个不同的特权级, 特权级切换: 中断发生的时候, 使用系统调用的时候
  • 分页机制: 使用之后, CPU操控的就不再是实际的内存空间, 会把地址分为三部分, 之后根据这个进行地址的索引