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字节进行的, 这是读取的最小单位
还有一块自己的内存, 可以在任意位置进行读取
BIOS会检查硬盘的第一个扇区的最后两个字节如果是上面的两个数据, 就会进行拷贝, 之后进行执行, 拷贝到0x7c00位置, os.bin文件会通过dd命令写入disk.img文件的开头
没有加载有效字节的结果
#include "os.h"
// 声明本地以下符号是全局的,在其它源文件中可以访问
.global _start
// 指定以下的代码生成16位的机器指令,这样才能在启动时的实模式下运行
.code16
// 以下是代码区
.text
_start:
jmp .
.org 0x1fe //十进制是510,在这里跳转到对应的位置
.byte 0x55, 0xaa
识别成功
指令执行的位置到达对应的位置
x86处理器编程模型
内核寄存器
- 通用寄存器
可以利用不同的名字对各个寄存器的不同位置进行操作
- 段寄存器
早期的十六位CPU访问的范围比较小, 所以设置一个起始位置, 之后在上面进行叠加访问
段寄存器的值会进行左移四位, 用来扩大储存数据的位置
在这时候把这些全部设置为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
- 配置栈空间
设置为栈空间的末尾地址
#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
在后面填进去一些数据
img文件以及运行时候内存的文件, 在运行的时候使用这一个命令可以查看对应位置的内存
进入保护模式
在进入系统的时候进入的实际上是16位的CPU工作模式(实模式), 之后需要进入32位的模式
通过段寄存器记录一个基地址, 之后通过偏移量进行访问数据, 这一种模式是比较危险的, 可以随意设置段寄存器的位置, 但是进入保护模式之后对于存储的访问会进行一些检查, 还会检查是否超过边界, 还会有中断向量表以及多任务运行的功能
CPU需要一些设置, 首先需要一个GDP表, 还需要修改一个寄存器, 还需要修改段寄存器的设置, 这时候存放的是索引, 这个表的位置保存在GDTR寄存器里面, 表里面记录有大小, 位置, 权限等
每一个字段是8字节
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: 段的类型
- 第一个表段必须为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
- 调试
使用命令
info registers
分页机制
在不使用分页机制的时候, 我们看到的是物理内存, 物理内存有多大, 我们就可以使用多大的内存
使用内存分页机制, 我们就可以扩充访问的地址范围, 也可以实现权限的细分, 实际上就是实现虚拟内存, 将地址进行映射, 看到的内存更大了, 但是实际上可以使用的内存的大小还是不变的
访问过程: 根据段寄存器找到对应的记录的GDT表, 之后根据表找到自己的使用的内存, 加上偏移量之后就是实际的地址, 这一个地址会通过分页机制里面的页表, 页表的地址放在CR3的寄存器里面
会根据传过来的数据的地址分段之后进行访问不同的页表, 获得一个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 .
分页打开前后, 权限是用户可以读写
//这个表是否有效
#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 .
在修改之后发现两个位置是同步的, 可以直接操控第二个映射地址或者采用第一个映射的地址
总结
也就是说,在没有开启分页机制时,由程序员给出的逻辑地址,需要先通过分段机制转换成物理地址。但在开启分页机制后,逻辑地址仍然要先通过分段机制进行转换,只不过转换后不再是最终的物理地址,而是线性地址,然后再通过一次分页机制转换,得到最终的物理地址。
开启定时器
8253定时器
一般情况下是内置在CPU里面的, 不需要了解所有的, 因为现在已经内置在CPU里面很多并不需要了解
定时器连接在IRQ0, 一共有三个定时器,其他的中断进行屏蔽
IDT表
IDTR寄存器进行控制, 中断发生的时候会参考这一个表进行进行处理
定时器的中断会使用0x20位置的中断
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表对实际的物理地址权限等进行分类, 最后在进行操作
切换到低特权模式
最高等级一般是操作系统, 可以访问所有的资源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命令从中断返回的指令, 需要对栈进行配置
这里相当于从一个中断函数进行返回, 在进入中断的时候会自动保存上面右侧显示的寄存器内容, SS栈的段寄存器, ESP栈的指针, CS代码段, EIP指令对应的地址, 返回的时候会自动设置这些值
这设置设个的时候主要是需要设置中断标志位为使能中断,设置为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)
进入特权级3的模式
- 之后需要给这一个特权级的任务定义一个数组作为栈
最好先把中断关掉, 否则会死机
实现任务切换
x86实现了任务切换的硬件上的支持, 只需要给每一个任务一个TSS就可以方便的使用一条指令进行任务切换
这里面存放的是当前任务的状态, CPU会自动把寄存器的状态存放在这里面, 这里面的SS, ESP等有多个是给不同特权级的时候使用不同的段
在产生中断的时候会从特权级0的位置找到栈, 之后执行中断相关的内容
需要在GDT里面使用两个段来记录两个任务的TSS, 通过GDT的描述符进行区分
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));
}
跳转之前的任务1
跳转之后, 有一些寄存器会发生自动的更改, 这时候的段寄存器发生了变化, 由于是在中断中发生的
这是任务1没有开始执行的时候的数据
进入任务1以后会写入寄存器里面
增加系统调用
允许应用调用操作系统的一些函数, 主要是由于权限, 需要在特区级下面运行一些操作
页表相关设置的时候有一个设置是PDE_U位, 这时候用户就可以访问这一段地址, 否则就是需要系统操作级来进行操作
这里填写的是选择子, 指向一个代码段, 也是注册在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++;
}
}
显示一个字符
有80列25行
会按照之前设置的参数, 会自动取出来三个参数
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的作用
之前使用的时候直接把段设置为同一个代码段, 所以进程空间被两个进程同时使用, 相互之间的数据可以被看到
CS和DS使用相同的值但是访问的内存的位置是不同的
使用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操控的就不再是实际的内存空间, 会把地址分为三部分, 之后根据这个进行地址的索引