操作系统是如何实现的?

2026-05-27 16:541阅读0评论SEO问题
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计2970个文字,预计阅读时间需要12分钟。

操作系统是如何实现的?

博客网址:www.shicoder.top微信:18223081347欢迎加群交流:452380935本次我们针对内核进行完善,主要包括全局描述符的加载、任务调度、中断等,以及全局描述符的加载。我们回顾下,这是否符合内核的定义?

博客网址:www.shicoder.top
微信:18223081347
欢迎加群聊天 :452380935

这一次我们来对内核进行完善,主要包括全局描述符的加载、任务调度、中断等

全局描述符的加载

我们回顾下,是不是在loader中有关于全局描述符的一些代码

prepare_protected_mode: cli; 关闭中断 ; 打开A20线 in al, 0x92 or al, 0b10 ; 第1位置1 out 0x92, al ; 加载GDT lgdt [gdt_ptr] ; 启动保护模式 mov eax, cr0 or eax, 1 ; 第0位置1 mov cr0, eax ; 用跳转来刷新缓存,启用保护模式 jmp dword code_selector:protect_mode

我们在准备进入保护模式的时候,将gdt_ptr指向的地方,加载到gdt寄存器中,那么难道进入保护模式,也就是内核阶段,难道就不用了吗,当然不是,而且你想,我们在loader中,只有2个段,一个是代码段,一个数据段,而一共有8192个段,那么其他段在内核使用的时候,怎么加载呢,因此我们需要在内核中重新定义一个数组,先初始化8192个(当然在我这个内核中,用不到这么多,其实只初始化了128个),然后先将之前保护模式下gdt寄存器的值赋值到这个数组中,再将数组的地址加载到gdt寄存器中,那么知道了这个步骤,我们就开始吧

操作系统是如何实现的?

#define GDT_SIZE 128 // 本身有8192个,但是我们在这里用不到这么多 // 全局描述符 typedef struct descriptor_t /* 共 8 个字节 */ { unsigned short limit_low; // 段界限 0 ~ 15 位 unsigned int base_low : 24; // 基地址 0 ~ 23 位 16M unsigned char type : 4; // 段类型 unsigned char segment : 1; // 1 表示代码段或数据段,0 表示系统段 unsigned char DPL : 2; // Descriptor Privilege Level 描述符特权等级 0 ~ 3 unsigned char present : 1; // 存在位,1 在内存中,0 在磁盘上 unsigned char limit_high : 4; // 段界限 16 ~ 19; unsigned char available : 1; // 该安排的都安排了,送给操作系统吧 unsigned char long_mode : 1; // 64 位扩展标志 unsigned char big : 1; // 32 位 还是 16 位; unsigned char granularity : 1; // 粒度 4KB 或 1B unsigned char base_high; // 基地址 24 ~ 31 位 } _packed descriptor_t; // 段选择子 typedef struct selector_t { u8 RPL : 2; u8 TI : 1; u16 index : 13; } selector_t; // 全局描述符表指针 typedef struct pointer_t { u16 limit; u32 base; } _packed pointer_t; void gdt_init();

先将结构定义一下,因为在内核中,我们可以使用c语言编写,因此这里的定义不像loader那样用汇编,是不是感觉也看的简单点啦,最主要是一个gdt_init函数,它就是我们刚刚那个步骤的主要实现

descriptor_t gdt[GDT_SIZE]; // 内核全局描述符表 pointer_t gdt_ptr; // 内核全局描述符表指针 // 初始化内核全局描述符表 void gdt_init() { DEBUGK("init gdt!!!\n"); // 在loader.asm中,已经有三个描述符了,因此GDTR寄存器有3个了 asm volatile("sgdt gdt_ptr"); // 读取GDTR寄存器到gdt_ptr指向的地方 memcpy(&gdt, (void *)gdt_ptr.base, gdt_ptr.limit + 1); // 此时gdt这个数组前3个有值,后面125个是0 gdt_ptr.base = (u32)&gdt; gdt_ptr.limit = sizeof(gdt) - 1; asm volatile("lgdt gdt_ptr\n"); // 将gdt_ptr指向的值写入到GDTR寄存器 ,此时GDTR寄存器有128个全局描述符 }

还是挺简单的嘛,就这么一点,注意,我们的内核只有一个128的数组,并没实现8192,但正常来说,linux肯定是8192

任务及调度

简单来说,一个任务可以想成一个进程,那么每个进程都需要有自己的一个栈来保存自己运行时候所需要的信息,在这次的代码编写中,为了简化,一个进程的栈占一页内存,同时其结构如下

因此任务调度就是将此时的栈切换为下一个进程的栈,那么切换肯定要知道切换之后要保存哪些东西,这个是由ABI来规定的,一个进程有自己的寄存器值,ABI规定,比如进程a要切换到进程b,那么进程a要自己保存下面三个

  • eax
  • ecx
  • edx

进程b要替进程a保存以下5个

  • ebx
  • esi
  • edi
  • ebp
  • esp

知道上面的理论,我们就可以进行切换了

创建进程

我们上面说到一个进程需要一个栈,那么我们就给这个栈创建一个结构体

typedef struct task_t { u32 *stack; // 内核栈 } task_t;

此时就是按照上面那个图,设置一些值

#define PAGE_SIZE 0x1000 // 4KB 表示一页 每一页里面存放进程的信息和进程的栈信息 task_t *a = (task_t *)0x1000; // 进程a的栈的初始地址,然后每个进程的栈有1页 u32 thread_a() { while (true) { printk("A"); schedule(); } } static void task_create(task_t *task, target_t target) { // 此时stack为这个进程的栈的最高地址 u32 stack = (u32)task + PAGE_SIZE; // 进程的栈的最高地址往下一点,就是存放task_frame_t stack -= sizeof(task_frame_t); task_frame_t *frame = (task_frame_t *)stack; frame->ebx = 0x11111111; frame->esi = 0x22222222; frame->edi = 0x33333333; frame->ebp = 0x44444444; frame->eip = (void *)target; task->stack = (u32 *)stack; } task_create(a, thread_a); 进程调度

其中最重要的函数schedule中的task_switch由于需要对寄存器进行操作,因此采用汇编实现

void schedule() { // 第一次进入时候,current是main进程,后续才是ababa这样一直切换 task_t *current = running_task(); task_t *next = current == a ? b : a; task_switch(next); }

task_switch: push ebp mov ebp, esp push ebx push esi push edi mov eax, esp; and eax, 0xfffff000; current mov [eax], esp ;=======上面是保存切换前的环境,下面是恢复即将要切换的线程环境,其实最重要的一点就是 ; esp的值,esp决定了此时在哪个进程的栈中 mov eax, [ebp + 8]; next mov esp, [eax] pop edi pop esi pop ebx pop ebp ret

差不多到此时,栈的切换完成,一旦ret,就会到进程a的代码

中断

上面可以看出我们是使用schedule来自己进行切换,而正常情况会出现抢占式的切换,就比如自己遇到一些情况,比如打印机需要纸,就会自动切换进程,这样就要使用中断来切换,中断就是一个函数

中断向量表

由于中断就是一个函数,因此有一个表来存放这个函数的地址,到时候调用中断时候,去表里面查询调用的函数序号就知道具体调用什么函数,在实模式下,处理器要求将它们的入口点集中存放到内存中从物理地址 0x000 开始,到 0x3ff 结束,共 1 KB 的空间内,一共256个中断向量,中断向量是指向中断函数的指针。一个向量包括4个字节,前2个字节为段内偏移,后2个字节是段地址,调用方式为int num,下面我们来试一下实模式下的中断,我们将boot.asm改成下面,把跳转到loader的部分先注释掉

; 将该代码放在0x7c00 因为由007内核加载器.md文件可知,MBR加载区域就是从0x7c00开始 [org 0x7c00] ;设置屏幕模式为文本模式,清除屏幕 mov ax, 3 int 0x10 ;初始化段寄存器 mov ax, 0 mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ;====================测试中断 mov word [0x54 * 4], interrupt mov word [0x54 * 4 + 2],0 int 0x54 ;==================== jmp $ interrupt: mov si, string call print iret string: db ".",0 ; print 函数需要三条语句 ; mov ah 0x0e mov al 字符串 int 0x10 print: mov ah, 0x0e .next: mov al, [si] ; si相当于是指针,不断向后移动,知道遇到booting字符串最后的0 cmp al, 0 jz .done int 0x10 inc si jmp .next .done: ret

关键是这三行

mov word [0x54 * 4], interrupt mov word [0x54 * 4 + 2],0 int 0x54

其实就是我们在使用 int num调用一个中断时候,先将我们要调用的函数注册进来,前面说到,一共256个,每个4字节,因此比如上面那个是int 0x54,则将interrupt函数注册到0x54 * 4的地方

可以看到成功打印出.

但是在保护模式下,由于我们很少使用段地址和段内偏移,因此很少使用上面的方式,但还是将这种思想保留了下来,下面我们来说下保护模式下的中断把

中断描述符表

在实模式下的中断向量表,在保护模式下变为了中断描述符表,实模式下的中断向量,在保护模式下变为了中断描述符,我们先来说中断描述符把

我们知道中断向量其实就是指向函数的地址,但是中断描述符因为空间变大了,因此添加了很多其他的东西,先看下它的结构体把

typedef struct gate_t { u16 offset0; // 段内偏移 0 ~ 15 位 u16 selector; // 代码段选择子 u8 reserved; // 保留不用 u8 type : 4; // 任务门/中断门/陷阱门 u8 segment : 1; // segment = 0 表示系统段 u8 DPL : 2; // 使用 int 指令访问的最低权限 u8 present : 1; // 是否有效 u16 offset1; // 段内偏移 16 ~ 31 位 } _packed gate_t;

其中的offset1offset2可以想成指向的函数的地址,当然这里分割为高15位地址和低15位地址

同样,将所有的中断描述符聚合成一个表,当然就是中断描述符表,同理有一个特殊的寄存器指向这个中断描述符表,它就是IDT register,同样有两个指令

lidt [idt_ptr]; 加载 idt 将idt_ptr指向的地方保存到IDT register sidt [idt_ptr]; 保存 idt 将IDT register存放的值放在idt_ptr中

下面我们就来实现我们系统中的中断描述符表把

global interrupt_handler interrupt_handler: xchg bx, bx push message call printk add esp, 4 xchg bx, bx iret section .data message: db "default interrupt", 10, 0

gate_t idt[IDT_SIZE]; pointer_t idt_ptr; // 本身这个变量是针对全局描述符表,因为中断描述符表的指针一样,所以公用 extern void interrupt_handler(); void interrupt_init() { for (size_t i = 0; i < IDT_SIZE; i++) { gate_t *gate = &idt[i]; gate->offset0 = (u32)interrupt_handler & 0xffff; gate->offset1 = ((u32)interrupt_handler >> 16) & 0xffff; gate->selector = 1 << 3; // 代码段 gate->reserved = 0; // 保留不用 gate->type = 0b1110; // 中断门 gate->segment = 0; // 系统段 gate->DPL = 0; // 内核态 gate->present = 1; // 有效 } idt_ptr.base = (u32)idt; idt_ptr.limit = sizeof(idt) - 1; // BMB; asm volatile("lidt idt_ptr\n"); }

void kernel_init() { console_init(); gdt_init(); interrupt_init(); return; }

_start: call kernel_init ; main.c返回 int 0x80; 调用 0x80 中断函数 系统调用,因此在初始化中,将256整个中断描述符表的每一项中断描述符都指向interrupt_handler,所以随便调用哪个都可以 jmp $

我们来说下它的流程把,首先是在kernel的主函数kernel_init函数中使用interrupt_init创建了一个大小为128个中断描述符表,且初始化了每个中断描述符,每个描述符指向的函数地址都是interrupt_handler,这个函数就是打印default interrupt,然后注意,当kernel_init函数返回时候,就到了_start函数,里面使用了int 0x80,其实这个时候不需要指定哪个数,因为128个中断描述符都是一样的,int 0x69也一样,小于128就行

结果出来啦

本文共计2970个文字,预计阅读时间需要12分钟。

操作系统是如何实现的?

博客网址:www.shicoder.top微信:18223081347欢迎加群交流:452380935本次我们针对内核进行完善,主要包括全局描述符的加载、任务调度、中断等,以及全局描述符的加载。我们回顾下,这是否符合内核的定义?

博客网址:www.shicoder.top
微信:18223081347
欢迎加群聊天 :452380935

这一次我们来对内核进行完善,主要包括全局描述符的加载、任务调度、中断等

全局描述符的加载

我们回顾下,是不是在loader中有关于全局描述符的一些代码

prepare_protected_mode: cli; 关闭中断 ; 打开A20线 in al, 0x92 or al, 0b10 ; 第1位置1 out 0x92, al ; 加载GDT lgdt [gdt_ptr] ; 启动保护模式 mov eax, cr0 or eax, 1 ; 第0位置1 mov cr0, eax ; 用跳转来刷新缓存,启用保护模式 jmp dword code_selector:protect_mode

我们在准备进入保护模式的时候,将gdt_ptr指向的地方,加载到gdt寄存器中,那么难道进入保护模式,也就是内核阶段,难道就不用了吗,当然不是,而且你想,我们在loader中,只有2个段,一个是代码段,一个数据段,而一共有8192个段,那么其他段在内核使用的时候,怎么加载呢,因此我们需要在内核中重新定义一个数组,先初始化8192个(当然在我这个内核中,用不到这么多,其实只初始化了128个),然后先将之前保护模式下gdt寄存器的值赋值到这个数组中,再将数组的地址加载到gdt寄存器中,那么知道了这个步骤,我们就开始吧

操作系统是如何实现的?

#define GDT_SIZE 128 // 本身有8192个,但是我们在这里用不到这么多 // 全局描述符 typedef struct descriptor_t /* 共 8 个字节 */ { unsigned short limit_low; // 段界限 0 ~ 15 位 unsigned int base_low : 24; // 基地址 0 ~ 23 位 16M unsigned char type : 4; // 段类型 unsigned char segment : 1; // 1 表示代码段或数据段,0 表示系统段 unsigned char DPL : 2; // Descriptor Privilege Level 描述符特权等级 0 ~ 3 unsigned char present : 1; // 存在位,1 在内存中,0 在磁盘上 unsigned char limit_high : 4; // 段界限 16 ~ 19; unsigned char available : 1; // 该安排的都安排了,送给操作系统吧 unsigned char long_mode : 1; // 64 位扩展标志 unsigned char big : 1; // 32 位 还是 16 位; unsigned char granularity : 1; // 粒度 4KB 或 1B unsigned char base_high; // 基地址 24 ~ 31 位 } _packed descriptor_t; // 段选择子 typedef struct selector_t { u8 RPL : 2; u8 TI : 1; u16 index : 13; } selector_t; // 全局描述符表指针 typedef struct pointer_t { u16 limit; u32 base; } _packed pointer_t; void gdt_init();

先将结构定义一下,因为在内核中,我们可以使用c语言编写,因此这里的定义不像loader那样用汇编,是不是感觉也看的简单点啦,最主要是一个gdt_init函数,它就是我们刚刚那个步骤的主要实现

descriptor_t gdt[GDT_SIZE]; // 内核全局描述符表 pointer_t gdt_ptr; // 内核全局描述符表指针 // 初始化内核全局描述符表 void gdt_init() { DEBUGK("init gdt!!!\n"); // 在loader.asm中,已经有三个描述符了,因此GDTR寄存器有3个了 asm volatile("sgdt gdt_ptr"); // 读取GDTR寄存器到gdt_ptr指向的地方 memcpy(&gdt, (void *)gdt_ptr.base, gdt_ptr.limit + 1); // 此时gdt这个数组前3个有值,后面125个是0 gdt_ptr.base = (u32)&gdt; gdt_ptr.limit = sizeof(gdt) - 1; asm volatile("lgdt gdt_ptr\n"); // 将gdt_ptr指向的值写入到GDTR寄存器 ,此时GDTR寄存器有128个全局描述符 }

还是挺简单的嘛,就这么一点,注意,我们的内核只有一个128的数组,并没实现8192,但正常来说,linux肯定是8192

任务及调度

简单来说,一个任务可以想成一个进程,那么每个进程都需要有自己的一个栈来保存自己运行时候所需要的信息,在这次的代码编写中,为了简化,一个进程的栈占一页内存,同时其结构如下

因此任务调度就是将此时的栈切换为下一个进程的栈,那么切换肯定要知道切换之后要保存哪些东西,这个是由ABI来规定的,一个进程有自己的寄存器值,ABI规定,比如进程a要切换到进程b,那么进程a要自己保存下面三个

  • eax
  • ecx
  • edx

进程b要替进程a保存以下5个

  • ebx
  • esi
  • edi
  • ebp
  • esp

知道上面的理论,我们就可以进行切换了

创建进程

我们上面说到一个进程需要一个栈,那么我们就给这个栈创建一个结构体

typedef struct task_t { u32 *stack; // 内核栈 } task_t;

此时就是按照上面那个图,设置一些值

#define PAGE_SIZE 0x1000 // 4KB 表示一页 每一页里面存放进程的信息和进程的栈信息 task_t *a = (task_t *)0x1000; // 进程a的栈的初始地址,然后每个进程的栈有1页 u32 thread_a() { while (true) { printk("A"); schedule(); } } static void task_create(task_t *task, target_t target) { // 此时stack为这个进程的栈的最高地址 u32 stack = (u32)task + PAGE_SIZE; // 进程的栈的最高地址往下一点,就是存放task_frame_t stack -= sizeof(task_frame_t); task_frame_t *frame = (task_frame_t *)stack; frame->ebx = 0x11111111; frame->esi = 0x22222222; frame->edi = 0x33333333; frame->ebp = 0x44444444; frame->eip = (void *)target; task->stack = (u32 *)stack; } task_create(a, thread_a); 进程调度

其中最重要的函数schedule中的task_switch由于需要对寄存器进行操作,因此采用汇编实现

void schedule() { // 第一次进入时候,current是main进程,后续才是ababa这样一直切换 task_t *current = running_task(); task_t *next = current == a ? b : a; task_switch(next); }

task_switch: push ebp mov ebp, esp push ebx push esi push edi mov eax, esp; and eax, 0xfffff000; current mov [eax], esp ;=======上面是保存切换前的环境,下面是恢复即将要切换的线程环境,其实最重要的一点就是 ; esp的值,esp决定了此时在哪个进程的栈中 mov eax, [ebp + 8]; next mov esp, [eax] pop edi pop esi pop ebx pop ebp ret

差不多到此时,栈的切换完成,一旦ret,就会到进程a的代码

中断

上面可以看出我们是使用schedule来自己进行切换,而正常情况会出现抢占式的切换,就比如自己遇到一些情况,比如打印机需要纸,就会自动切换进程,这样就要使用中断来切换,中断就是一个函数

中断向量表

由于中断就是一个函数,因此有一个表来存放这个函数的地址,到时候调用中断时候,去表里面查询调用的函数序号就知道具体调用什么函数,在实模式下,处理器要求将它们的入口点集中存放到内存中从物理地址 0x000 开始,到 0x3ff 结束,共 1 KB 的空间内,一共256个中断向量,中断向量是指向中断函数的指针。一个向量包括4个字节,前2个字节为段内偏移,后2个字节是段地址,调用方式为int num,下面我们来试一下实模式下的中断,我们将boot.asm改成下面,把跳转到loader的部分先注释掉

; 将该代码放在0x7c00 因为由007内核加载器.md文件可知,MBR加载区域就是从0x7c00开始 [org 0x7c00] ;设置屏幕模式为文本模式,清除屏幕 mov ax, 3 int 0x10 ;初始化段寄存器 mov ax, 0 mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ;====================测试中断 mov word [0x54 * 4], interrupt mov word [0x54 * 4 + 2],0 int 0x54 ;==================== jmp $ interrupt: mov si, string call print iret string: db ".",0 ; print 函数需要三条语句 ; mov ah 0x0e mov al 字符串 int 0x10 print: mov ah, 0x0e .next: mov al, [si] ; si相当于是指针,不断向后移动,知道遇到booting字符串最后的0 cmp al, 0 jz .done int 0x10 inc si jmp .next .done: ret

关键是这三行

mov word [0x54 * 4], interrupt mov word [0x54 * 4 + 2],0 int 0x54

其实就是我们在使用 int num调用一个中断时候,先将我们要调用的函数注册进来,前面说到,一共256个,每个4字节,因此比如上面那个是int 0x54,则将interrupt函数注册到0x54 * 4的地方

可以看到成功打印出.

但是在保护模式下,由于我们很少使用段地址和段内偏移,因此很少使用上面的方式,但还是将这种思想保留了下来,下面我们来说下保护模式下的中断把

中断描述符表

在实模式下的中断向量表,在保护模式下变为了中断描述符表,实模式下的中断向量,在保护模式下变为了中断描述符,我们先来说中断描述符把

我们知道中断向量其实就是指向函数的地址,但是中断描述符因为空间变大了,因此添加了很多其他的东西,先看下它的结构体把

typedef struct gate_t { u16 offset0; // 段内偏移 0 ~ 15 位 u16 selector; // 代码段选择子 u8 reserved; // 保留不用 u8 type : 4; // 任务门/中断门/陷阱门 u8 segment : 1; // segment = 0 表示系统段 u8 DPL : 2; // 使用 int 指令访问的最低权限 u8 present : 1; // 是否有效 u16 offset1; // 段内偏移 16 ~ 31 位 } _packed gate_t;

其中的offset1offset2可以想成指向的函数的地址,当然这里分割为高15位地址和低15位地址

同样,将所有的中断描述符聚合成一个表,当然就是中断描述符表,同理有一个特殊的寄存器指向这个中断描述符表,它就是IDT register,同样有两个指令

lidt [idt_ptr]; 加载 idt 将idt_ptr指向的地方保存到IDT register sidt [idt_ptr]; 保存 idt 将IDT register存放的值放在idt_ptr中

下面我们就来实现我们系统中的中断描述符表把

global interrupt_handler interrupt_handler: xchg bx, bx push message call printk add esp, 4 xchg bx, bx iret section .data message: db "default interrupt", 10, 0

gate_t idt[IDT_SIZE]; pointer_t idt_ptr; // 本身这个变量是针对全局描述符表,因为中断描述符表的指针一样,所以公用 extern void interrupt_handler(); void interrupt_init() { for (size_t i = 0; i < IDT_SIZE; i++) { gate_t *gate = &idt[i]; gate->offset0 = (u32)interrupt_handler & 0xffff; gate->offset1 = ((u32)interrupt_handler >> 16) & 0xffff; gate->selector = 1 << 3; // 代码段 gate->reserved = 0; // 保留不用 gate->type = 0b1110; // 中断门 gate->segment = 0; // 系统段 gate->DPL = 0; // 内核态 gate->present = 1; // 有效 } idt_ptr.base = (u32)idt; idt_ptr.limit = sizeof(idt) - 1; // BMB; asm volatile("lidt idt_ptr\n"); }

void kernel_init() { console_init(); gdt_init(); interrupt_init(); return; }

_start: call kernel_init ; main.c返回 int 0x80; 调用 0x80 中断函数 系统调用,因此在初始化中,将256整个中断描述符表的每一项中断描述符都指向interrupt_handler,所以随便调用哪个都可以 jmp $

我们来说下它的流程把,首先是在kernel的主函数kernel_init函数中使用interrupt_init创建了一个大小为128个中断描述符表,且初始化了每个中断描述符,每个描述符指向的函数地址都是interrupt_handler,这个函数就是打印default interrupt,然后注意,当kernel_init函数返回时候,就到了_start函数,里面使用了int 0x80,其实这个时候不需要指定哪个数,因为128个中断描述符都是一样的,int 0x69也一样,小于128就行

结果出来啦