拼一个自己的操作系统 SnailOS 0.03的实现
拼一个自己的操作系统SnailOS0.03源代码-Linux文档类资源-CSDN下载
操作系统SnailOS学习拼一个自己的操作系统-Linux文档类资源-CSDN下载
SnailOS0.00-SnailOS0.00-其它文档类资源-CSDN下载
用户进程的实现
在前面的章节,我们先后讲了保护模式、分页机制、中断和异常、内存管理、内核线程、简单的线程同步机制等等问题。这一张我们终于迎来了进程。进程其实就是处理器所谓的任务。当然任务又分为系统任务和用户任务。而我们要实现的就是用户级的任务。那么为什么要实现用户任务呢?其实大家不难看出,前面我们所运行的程序,都是运行在特权级0的基础上的,这些代码不但有权限又该内核中的任何数据,而且有使用处理器所有指令的权限,也就是说,它想改全局表、想关中断、想关闭分页模式,甚至发送硬件的输入输出指令写硬盘分区表等,都是可以做到的。这样的无法无天、任意妄为的行为,我们自己写程序当然是不会去做的,可以将来如果真的运行了硬盘上的可执行文件,这些文件又来自于某个有意无意的破坏者,那可就说不定了。因此上,处理器硬件上的任务机制,可不就是光任务那么简单,在任务切换的过程中,它随时随地进行特权检查,从而有效地保护操作系统的核心代码不被破坏。我们自己拼出的操作系统,即使是简陋至极,当然也不会错过,处理器所赐予的保护功能了。
任务状态段
|
任务状态段(task status segment)的最短结构 |
|
|
|
|
|
|
低地址 |
0 |
上一个任务的任务状态段的选择符 |
| |
4 |
esp0 |
| |
8 |
ss0 |
| |
12 |
esp1 |
| |
16 |
ss1 |
| |
20 |
esp2 |
| |
24 |
ss2 |
| |
28 |
cr3 |
| |
32 |
eip |
| |
36 |
eflags |
| |
40 |
eax |
| |
44 |
ebx |
| |
48 |
ecx |
| |
52 |
edx |
| |
56 |
esp |
| |
60 |
ebp |
| |
64 |
esi |
| |
68 |
edi |
| |
72 |
es |
| |
76 |
cs |
| |
80 |
ss |
| |
84 |
ds |
| |
88 |
fs |
| |
92 |
gs |
| |
96 |
任务局部描述表的选择符 |
| |
100 |
一个调试标志和输入输出许可位图的基地址 |
\/ |
|
|
高地址 |
通过上图,相信大家已经明白了,任务状态段仅仅是位于内存中的一个数据结构。当然它必须是连续的,而且在分页模式下,不能出现在物理地址不连续的多个虚拟页面上。还是来看看我们的代码吧。
【tss.h】
// tss.h 创建者:至强 创建时间:2022年8月
#ifndef __TSS_H
#define __TSS_H
#include "thread.h"
/*
任务状态段(task status segment)的结构。backlink是前一个任务的
任务状态段选择符,当处理器处于任务嵌套状态下(这个字段我们不用)
的任务切换时,处理器会把前一任务的任务状态段选择符自动更新到这里。
下面的esp?, ss?分别是任务的0、1、2级堆栈。一个任务在运行期间,可
以处于4个运行级别,从高到低分别是0、1、2、3。那么为什么没有3级栈
呢?3级被任务是用户任务,任务得以运行时,处理器中的esp和ss已经
指明了栈段和栈指针的具体位置。也就是我们在初始化任务时,已经为任务
准备好了esp和ss。下面直到ldt之前的字段就不用详细说明了吧。它们分别
是页目录表基地址寄存器、标志寄存器、通用寄存器以及段寄存器。ldt是
ldt在全局描述表中的选择符。trace是调试陷阱标志字段,如果该字段被置
位,切换到此任务时则产生调试异常,该字段为2字节中的最低位。io_base
是io许可位图基地址,长度为2字节,它是tss开始到许可位图的偏移值,如
果该字段长度大于或者等于tss段的长度,就表示没有io许可位图。而tss段
的长度位于tss在全局描述表的描述符中。
*/
struct tss {
unsigned int backlink, esp0, ss0, esp1, ss1, esp2, ss2;
unsigned int cr3, eip, eflags, eax, ecx, edx, ebx;
unsigned int esp, ebp, esi, edi, es, cs, ss, ds, fs, gs;
unsigned int ldt;
unsigned short tarce, io_base;
};
void update_tss_esp(struct task* pthread);
void tss_init(void);
#endif
上面的代码,我们对于任务状态段结构中每一个字段都进行了详细的说明,这里也就不再赘述。值得注意的是任务状态段是任务(进程)在处理器眼中的唯一标识,有了任务状态段,任务的运行和切换就离我们不远了。
任务状态段在全局描述表中的描述符
任务状态段作为处理器中标识任务的唯一信息结构显然是十分重要的,因此它也像代码段和数据段一样,必须在某个描述表中,单独的建立一个描述符表项,来标识该段的存在和特殊属性,而且它属于系统段,为了彰显它的重要性,任务状态段的描述符只能出现在全局描述符表中。
任务门描述符
鉴于任务状态段的描述符只能出现在全局描述表中的局限性。处理器还提供了任务门描述符,改描述符中含有任务状态段在描述表中的选择符,因此任务门描述符可以出现在全局描述符表、中断描述符表以及局部描述符表中,从而可以间接地引用一个任务状态段。当然在引用一个任务门时,毕竟是切换到要引用的任务门的任务,因此当前任务的特权级(CPL)和任务门的选择符(RPL),必须在数值上小于等于任务门描述符中的(DPL),也就是符合数据段的访问原则。
任务切换的方式
x86的处理器还是蛮照顾客户的,居然提供了3大类任务切换的方式。第一种就是“中断或异常+任务门”的方式;第二种是call或者jmp指令+任务状态段在全局描述符表中的描述符或任务门的方法;第三种就是当处理器标志寄存器中的任务嵌套标志被置位时,用iret指令。好了废话不多说,我们在这里准备把这三种方式都简单地实验一下,当然尤其是第三种,也就是我们蜗牛系统使用的任务切换方式。
【tss.c】
// tss.c 创建者:至强 创建时间:2022年8月
#include "tss.h"
#include "thread.h"
#include "string.h"
#include "gdt_idt_init.h"
#include "global.h"
/*
我们的任务切换方式,完全来自于郑钢先生《操作系统真相还原》,因此
系统中有且只有一个tss段,选择符为3 * 8,也就是位于全局描述符表中
从0开始计算的第3个描述符。按照处理器规定,在分页机制下,该段不能
跨越在物理页不连续的虚拟地址边界。
*/
/*
这里我们为了把每种方式都实验一下,定义了两个任务状态段。tss1的选择符
是6 * 8。
*/
struct tss tss, tss1;
/*
在任务切换时,如果是一个进程,则将0级栈指针置为,任务pcb结构的最
高端。
*/
void update_tss_esp(struct task* pthread) {
tss.esp0 = (unsigned int)pthread + 4096;
}
/*
任务状态段、相关描述符及任务寄存器的初始化。
*/
void tss_init(void) {
/*
求取任务状态段的长度。
*/
unsigned int tss_size = sizeof(tss);
/*
清除垃圾数据。
*/
memset_(&tss, 0, tss_size);
memset_(&tss1, 0, tss_size);
/*
所有任务的0级栈段都和系统数据段是同一个段。
*/
tss.ss0 = 2 * 8;
tss1.ss0 = 2 * 8;
/*
io许可位图基地址,长度大于等于则可以看作无位图。
*/
tss.io_base = tss_size;
tss1.io_base = tss_size;
/*
更新了全局描述表中的各个描述符。但我们这里没有重新加载全局
描述表。
*/
create_gdt_desc(0, 0x0, 0x0, 0x0);
create_gdt_desc(1, 0x0, 0xc09a, 0xffffffff);
create_gdt_desc(2, 0x0, 0xc092, 0xffffffff);
/*
可以看到io许可位图基地址确实大于等于tss段的长度。0xe9是一个
32位的任务状态段,请自行查询系统段描述符的内容核对。
*/
create_gdt_desc(3, (unsigned int)&tss, 0xc0e9, tss_size - 1);
/*
下面两个是特权级为3的用户段描述符,可以看到与上面两个特权级为
0的段描述符除了属性稍有区别外,其他地方完全一样。仔细看来,
0特权级段的属性值只要加上0x60就得到了3特权级的段。
*/
create_gdt_desc(4, 0x0, 0xc0fa, 0xffffffff);
create_gdt_desc(5, 0x0, 0xc0f2, 0xffffffff);
create_gdt_desc(6, (unsigned int)&tss1, 0xc0e9, tss_size - 1);
/*
可以看到这里为了偷懒,直接把门描述符复制为全局描述符,这样一来,
全局描述符表中从0开始的第7个描述符就是一个任务门。它其实是指向第
6个任务状态段的描述符。
*/
unsigned long long* gdt_start = (unsigned long long*)0x6000;
unsigned long long* idt_start = (unsigned long long*)0x7000;
*(gdt_start + 7) = *(idt_start + 0x70);
/*
加载当前任务寄存器,由于有且只有一个tss,所以任务寄存器始终不变。
*/
/*
上边这段话是针对于我们最后实际使用的任务切换方式而言的,在实验中
我们切换了任务寄存器的内容。
*/
__asm__ __volatile__ ("ltr %w0"::"r"(3 * 8));
}
【intr.c】
(上面省略)
/*
出现在idt中的任务门描述符。该描述符的属性为0x8500。
*/
create_gate(0x70, 0, 6 * 8, 0x8500);
lidtr(&idtr_);
}
(中间省略)
/*
在任务切换实验阶段我们的进程,它本不因该出现在这里,笔者只是图个
懒吧。所作的名字也是文不对题。
*/
void _0x70_handler() {
// while(1)
printf_(" #task task task task@ ");
asm volatile ("jmp $3 * 8, $0");
asm volatile("iret");
}
(下面省略)
【kernel.c 节选】
(上面省略)
extern struct tss tss, tss1;
/*
第1任务(主函数)的任务状态段只需要初始化页目录表,这
主要可能是因为处理器在保存进程的上文时不更新cr3。如果
不返回到主任务,甚至不用对它的任务状态段进行任何处理,
当然也可以没有。
*/
// tss.eip = 0;
// tss.ss = tss.ds = tss.es = 0;
// tss.esp = 0;
tss.cr3 = 0x8000;
// tss.eflags = 0;
// tss.ss0 = 0;
// tss.esp0 = 0;
// tss.cs = 0;
// tss.ldt = 0;
/*
简单的初始化第2个任务的任务状态段。
*/
tss1.eip = (unsigned int)_0x70_handler;
tss1.ss = tss1.ds = tss1.es = 2 * 8;
tss1.esp = 0x8000;
tss1.cr3 = 0x8000;
tss1.eflags = 0x202;
tss1.ss0 = 2 * 8;
tss1.esp0 = 0x8000;
tss1.cs = 1 * 8;
tss1.ldt = 0;
asm volatile ("jmp $6 * 8, $10000");
// asm volatile ("call $6 * 8, $abcd");
// asm volatile ("jmp $7 * 8, $10000");
// asm volatile ("call $7 * 8, $abcd");
// asm volatile ("cli;int $0x70");
printf_("\n@@@task sucessful return!!!!!!");
while(1);
(下面省略)
当然在init()中还需要加入tss_init()函数,才能正常地运行。
上面有下划线的几句分别是jmp+任务状态段描述符、call+任务状态段描述符、jmp+任务门、call+任务门以及中断+任务门。而在任务中针对返回方式的不同,我们也使用了不同的指令。如call和中断必须使用iret指令返回,而由于jmp指令是有去无回的指令,所以必须用jmp指令明确的指出要切回的任务。下面是其中一张的截图,如果有兴趣大家可以都实验一下。
值得注意的是,为了简化任务切换,我们的任务都是工作在0特权级,这主要是可以免去特权级带来的很多问题。毕竟我们就是想简单的看看任务切换的模样罢了。还有一个小问题,我们在任务切换完成后使用了无限循环,来停止程序的运行。这主要是为了方便观察运行效果罢了。因为,如果线程得以运行的话,信息区上的信息很快就会烟消云散了,我们看不到什么有效结果。事实是在这里进行的任务切换不会对,下面的线程产生任何影响,它们活干的好着呢!
上面我们拿让大家望而生畏的几种任务切换方式,小试了牛刀。接下来我们就要讲我们所采取的任务切换方式,也就是iret方式了。
在讲解iret指令之前,为了让大家不至于迷糊,笔者还是要对栈的操作啰嗦一下。其实像上面的call、int之类的指令所进行的任务切换都是操作栈的,所以我们才能用iret成功的返回到主程序中。而由于上述操作未设计到特权级的变换,所以,任务切换的指令,也就是把标志寄存器、代码段寄存器、指令指针寄存器压入到0级栈。换个角度说,在特权级未变的情况下,笔者认为,即使待运行的任务中设置了0级栈段和栈指针,处理器也没有用,它只是简单的将标志寄存器、代码段寄存器、指令指针寄存器压入到0级栈,然后等待iret的到来就返回主程序(当然这只是笔者的臆断,而笔者没有在真机上测试过)。可到了特权级变换的时候,情况就又不一样了。处理器会先把栈段寄存器和栈指针寄存器一同压入到该进程的0级栈中,然后在压入特权级无变化的3个寄存器。那么在使用iret返回时处理器是怎么知道有无特权级变换的呢,笔者想仅仅通过cs就完全可以知道要切换的任务有没有改变特权级。这样一来,我们想要以iret实现特权级转化的工作,就变成操作堆栈的一系列骚操作了。不过仅仅这样还是不行的。我们还要重新设置一下eflags和加载我们待切换的任务寄存器、以及一些我们肯定会用到的数据段,才可以正确地运行下去。好了,闲言少叙,书归正言。我们还是直接上代码吧。
【intr.c 节选】
(上面省略)
/*
在任务切换实验阶段我们的进程,它本不因该出现在这里,笔者只是图个
懒吧。所作的名字也是文不对题。
*/
void _0x70_handler() {
// while(1)
kprintf_(" #task..................zzzz ");
// asm volatile ("jmp $3 * 8, $0");
// asm volatile("iret");
while(1);
}
【kernel.c 节选】
(上面省略)
// asm volatile ("call $7 * 8, $0xabcd");
// asm volatile ("cli;int $0x70");
asm volatile("pushl $5 * 8 + 3;\
pushl $5 * 8 + 3;\
pushfl;\
movl $0x202, (%esp);\
popfl;\
movl $6 * 8 , %eax;\
ltr %ax;\
popl %es;\
popl %ds;\
pushl $5 * 8 + 3;\
pushl $0xa000;\
pushfl;\
pushl $4 * 8 + 3;\
pushl $__0x70_handler;\
iret");
printf_("\n@@@task sucessful return!!!!!!");
while(1);
(下面省略)
我们主要来解释一下kernel.c中新增的内联汇编部分。前两句的push语句其实是为ltr之后的两句pop语句准备的,即是数据段要更新为特权级3的(也可以直接用mov指令更改,根据笔者的想法,此时更改了ds也是不用担心的,因为接下来的操作已经没有从ds相关的段存取内存数据的指令了。),接下来的三句是eflags改成我们想要的值。再下面的两句是加载任务寄存器,这里需要指出的是,任务寄存器中加载的选择符不可用立即数来加载,因此改用ax,还有任务寄存器不可用重复加载为相同的,如果加载了相同的将触发一般保护异常。后面的两句不用说了吧。最后的连续六句就是我们所说用iret进行的任务切换的精要了。意即先按照iret指令操作堆栈的顺序构造一个足够完美的堆栈,然后就进行任务切换。注意,push操作仅仅是改变栈中的数据,除了esp发生了有益的变化,它没有操作任何处理器的寄存器。标志寄存器赋值为0x202是开启中断的意思,所以再任务中实质上中断时打开的,这比较危险,好在我们没有开启任何硬件中断。还有在进入到进程中后,就不可能在返回了,原因是我们主程序中,根本没有保留任何可用状态。最有一个需要说明的地方,我们把原来的printf_改成了kprintf_原因,如果不改的话,会出现一般保护异常,笔者分析这主要是printf_中调用互斥的控制台输出函数时,用到了开关中断指令导致的。好了,下面就是效果图了。
补充内容:
从高特权级进入低特权级,据说只有iret和retf两种指令可以胜任。这主要是由处理器的特权检查所导致的。欲把iret改成retf你只需要将宏汇编中的pushf删除,并把iret替换成retf就好了。这个还是请大家自行实验吧。
接下来的真正实现进程切换的代码来自郑钢先生《操作系统真相还原》,笔者只是对很少的部分进行了修改。那么,有的朋友说了,不用那个就不行了。笔者的回答是,目前还真的不行呀,笔者也想融汇贯通,乃至超越前人,推陈出新。无奈能力在这摆着呢。而且一个事实是这种切换方式设计的特么太精巧了。简直是巧夺天工了。所以,笔者思来想去、权衡利弊,还是用这种方法。人家的东西好就是好,没什么可说的。
在列出代码之前让我们将上面极简任务切换的代码全部注释掉,或者干脆删掉好了。不过依照笔者这种生活的习惯,因为是过惯了穷日子,所以还是很舍不得删的。这就好像家中有很多破烂,可以卖了还钱的,可以是舍不得或者懒于去卖,于是一直堆在家中,直到烂掉为止。也不知道这是敝帚自珍、勤俭持家的好习惯呢?还是大包大揽、不懂取舍的穷人思维。好了,啰嗦多了,徒增烦恼。还是拿出全部代码,供大家分享,当然,适当地注释就在代码之中了。
【process.h】
// process.h 创建者:至强 创建时间:2022年8月
#ifndef __PROCESS_H
#define __PROCESS_H
#include "thread.h"
void start_process(void* filename_);
void page_dir_activate(struct task* pthread);
void process_activate(struct task* pthread);
unsigned int* create_page_dir(void);
void process_execute(void *filename, char* name, unsigned int level);
#endif
【process.c】
// process.c 创建者:至强 创建时间:2022年8月
#include "process.h"
#include "memory.h"
#include "string.h"
#include "debug.h"
#include "intr.h"
#include "tss.h"
#include "thread.h"
#include "global.h"
#include "sync.h"
//extern void exit(void);
/*
这个函数是kernel_thread调用的函数,而kernel_thread是switch_to
返回时,在线程栈中设置的eip。也就是说当进程第一次得到运行之前,
调度函数中的switch_to返回时,特殊的返回到kernel_thread中,而在
kernel_thread中,由于传递了start_process的地址及func_arg参数的
地址(位于线程栈中),可以顺利的调用start_process函数。
*/
void start_process(void* filename_) {
/*
真正得到运行的函数或者是将来的可执行文件。
*/
void* function = filename_;
/*
获取线程或进程的pcb。
*/
struct task* cur = running_thread();
cur->self_kstack = (unsigned int*)((unsigned int)
cur->self_kstack + sizeof(struct thread_stack));
/*
定位中断栈,以便切换进程或线程的上下文。请谨记,进程旨在第一次
运行时使用这个特殊的配置方法,之后有调度程序自动在进程的0级栈
上压入此类数据。
*/
struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack;
/*
通用寄存器通通设置为0。
*/
proc_stack->edi = proc_stack->esi = proc_stack->ebp =
proc_stack->esp_dummy = proc_stack->ebx = proc_stack->edx =
proc_stack->ecx = proc_stack->eax = 0;
/*
gs和fs暂时不用,ds、es、ss都是全局描述符表中从零开始第五个
描述符描述的段。
*/
proc_stack->gs = proc_stack->fs = 0;
proc_stack->ds = proc_stack->es = 5 * 8 + 3;
/*
进程最初的eip,也就是最初需要运行的地址。
*/
proc_stack->eip = (unsigned int)function;
/*
cs是全局描述符表中从零开始第四个描述符描述的段。
*/
proc_stack->cs = 4 * 8 + 3;
/*
在进程运行前,要对从栈中弹出的处理器标志寄存器进行设置,
它的基本涵义是从零开始第一位为固定为1,输入输出许可标志
为0,中断标志为1,这个标志必须为1,否则,进入应用程序后
就关闭中断,系统将陷入应用程序,其他程序不能得到调度。
*/
proc_stack->eflags = 0x202;
/*
有get_a_page的失败,暂时先用get_user_pages分配栈空间,可见
由于目前在进程运行阶段未有函数调用及变量定义使用用户栈的情况,
所以暂时不会有问题。
*/
proc_stack->esp = (unsigned int)get_user_pages(1) + 0x1000;
proc_stack->ss = 5 * 8 + 3;
asm volatile("movl %0, %%esp;jmp _exit"::"g"(proc_stack):"memory");
}
void page_dir_activate(struct task* pthread) {
/*
内核线程的页目录表物理基地址固定为0x00008000,而进程的页目录表
物理地址由内存管理模块动态分配。
*/
unsigned int pagedir_phyaddr = 0x00008000;
if(pthread->pgdir != NULL) {
pagedir_phyaddr = vaddr2paddr((unsigned int)pthread->pgdir);
}
__asm__ __volatile__("movl %0, %%cr3"::"r"(pagedir_phyaddr):"memory");
}
/*
如果内核线程,不需要改变页目录表的物理基地址,而进程不仅需要
修改页目录表的物理基地址,还要修改唯一的tss中的0级栈的指针,
也就是修改为自己pcb的最顶端。
*/
void process_activate(struct task* pthread) {
ASSERT(pthread != NULL);
page_dir_activate(pthread);
if(pthread->pgdir) {
update_tss_esp(pthread);
}
}
/*
这个函数存在问题,在此不做说明。
*/
unsigned int* create_page_dir(void) {
unsigned int* pagedir_vaddr = get_kernel_pages(1);
if(pagedir_vaddr == NULL) {
panic("create_page_dir: get_kernel_page failed!");
}
memset_((unsigned char*)pagedir_vaddr, 0, 4096);
memcpy_((unsigned char*)pagedir_vaddr,
(unsigned char*)0xffbfe000,
512 * 4);
memcpy_((unsigned char*)((int)pagedir_vaddr + 0xe00),
(unsigned char*)(0xffbfe000 + 0xe00),
8 * 4);
unsigned int new_pagedir_phyaddr = vaddr2paddr((unsigned int)
pagedir_vaddr);
pagedir_vaddr[1022] = new_pagedir_phyaddr + 7;
return pagedir_vaddr;
}
/*
主函数中,用于启动进程的方法。
*/
void process_execute(void *filename, char* name, unsigned int level) {
/*
创建进程的pcb。
*/
struct task* thread = get_kernel_pages(1);
/*
初始化pcb。
*/
init_thread(thread, name, 2, level);
/*
该函数是初始化进程虚拟地址的方法,但笔者认为它存在问题,
由于未找到具体原因,暂时搁置吧。
*/
uservaddr_memman_init(&thread->user_vir, 0x80000000,
0xe0000000 - 0x80000000);
/*
在进程运行阶段,申请内存必然产生竞争条件,因此这里初始
化互斥锁。
*/
lock_init(&thread->user_vir.lock);
/*
使start_process作为被kernnel_thread函数调用的方法。并传递
真正要运行的进程地址filename。strat_process和filename都将
被放置在线程栈中。
*/
thread_create(thread, start_process, filename);
/*
进程自己的页目录表。
*/
thread->pgdir = create_page_dir();
/*
通过关中断来实现原子操作。把进程添加到任务的就绪队列中。
*/
unsigned int old_status = intr_disable();
ASSERT((level >= 0) && (level <= 7));
thread_ready_list = &ready_list[thread->level];
ASSERT(!double_linked_list_find(thread_ready_list,
&thread->general_tag));
double_linked_list_append(thread_ready_list,
&thread->general_tag);
ASSERT(!double_linked_list_find(&thread_all_list,
&thread->all_list_tag));
double_linked_list_append(&thread_all_list,
&thread->all_list_tag);
set_intr_status(old_status);
}
【thread.h 节选】
(上面省略)
! 由于进程控制结构中含有mem_man的实体结构(非指针),
! 因此把原内存管理模块中mem_desc和mem_man的定义都移
! 这里。原来其他用到该结构定义的文件,可能需要稍作改动
! 这请大家自行处理吧!
/*
在这里笔者把它叫做内存的描述符,不知道这个名字是很贴切,
还是非常贴切。内存描述符不止一个,因此,它会用连续的内存
空间来存储,从而形成内存描述符数组。
*/
struct mem_desc {
/*
关键数据结构,也就是双向链表的第一次应用,便是在内存管理
模块中,它的作用是把内存描述符按照地址大小的顺序链接成有序
的空闲链表,从而方便内存的释放。
*/
struct list_node tag;
/*
start是内存块的起始地址,size是内存的字节大小,这里start会是
整页的开始地址,size会是页大小的整数倍。
*/
unsigned int start, size;
/*
status是该内存块的状态,0是内存描述符正在使用中且内存处于空闲状态,
1是内存描述符正在使用中且内存处于使用状态,-1是内存描述符处于未
使用状态,该内存描述符此时不会链接在链表中。内存描述符的链表将在
mem_man中定义。
*/
int status;
};
/*
字面的意思就是内存管理者,但是光从字面意思来理解是不够的。
每一种需要被管理的内存单元都会申请一个mem_man结构,按照我们
的内存管理模型来说,固定的内存管理者就会有3个,分别是内核物理
内存、内核虚拟内存、用户物理内存,而当运行用户态程序时(进程)
每个进程管理单元中都会带有一个内存管理者结构。
*/
struct mem_man {
/*
该指针指向内存描述符数组的首地址。
*/
struct mem_desc* md_a;
/*
无论是哪中内存,都会有一个空闲链表和一个使用链表,从而使分散
内存能够按照地址大小能够有序的排列。
*/
struct double_linked_list free, used;
struct lock lock;
};
/*
这是任务结构,也就是程序控制块,将开始于某个自然页的最低端。
*/
struct task {
/*
在线程中,通过switch_to()函数,该处用于存放线程切换时的内核栈指针。
因为任务结构开始于自然页的最低端,所以该变量作为任务结构的第一个
成员即是处于自然页的最低端。
*/
unsigned int* self_kstack;
/*
这是任务状态。
*/
enum task_status status;
/*
这是任务名字,最大16字节。
*/
char name[16];
/*
这是任务优先级,数值不会太大,所以字节类型就够了。
*/
unsigned char priority;
/*
同样的,这是任务时间片,初值设置为优先级,每次时钟中断自减1,
时间片用完,则该任务被调度器置为就绪态,换上其他任务运行。
*/
unsigned char ticks;
/*
这是任务在处理器上运行的总的时间片。
*/
unsigned int elapsed_ticks;
/*
任务在就绪队列中的节点,用于把任务从该队列中添加和删除。
*/
struct list_node general_tag;
/*
任务在全部任务队列中的节点,用于把任务从该队列中添加和删除。
*/
struct list_node all_list_tag;
/*
页目录表指针,内核线程没有自己单独的页目录表,该处为NULL。
*/
unsigned int* pgdir;
/*
每个线程都含有一个循环队列。
*/
struct ring_queue r;
/*
当前线程运行在哪一级。
*/
unsigned int level;
/*
进程控制块中,加入了内存管理的实体结构。
*/
struct mem_man user_vir;
/*
因为任务结构开始于自然页的最低端,而内核栈指针位于该页的高端
某处,为了防止在某个可能的时刻,内核栈覆盖任务结构,设置了这个
魔数。
*/
unsigned int stack_magic;
};
(下面省略)
【thread.c 节选】
(上面省略)
/*
当且仅当即将上处理器运行的代码为进程时,需要刷新页目录表及更改
进程的0级栈。
*/
process_activate(next);
switch_to(cur, next);
}
(下面省略)
【memory.c 节选】
(上面省略)
/*
在用户空间获取地址,返回值为虚拟地址。
*/
void* get_user_pages(unsigned int pg_cnt) {
/*
分配内存有竞争条件,因此申请互斥锁。
*/
lock_acquire(&user_phy.lock);
struct task* cur = running_thread();
void* vir_addr = malloc_page(&cur->user_vir, pg_cnt);
if(!vir_addr) {
/*
清除内存中的垃圾数据。
*/
memset_(vir_addr, 0, pg_cnt * 4096);
}
/*
解锁。
*/
lock_release(&user_phy.lock);
return vir_addr;
}
/*
置页表项目为无效。
*/
void page_table_pte_remove(unsigned int vir_addr) {
unsigned int* pte = (unsigned int*)(pte_ptr(vir_addr));
*pte &= ~0x1;
asm volatile("invlpg %0"::"m"(vir_addr):"memory");
}
/*
释放分配的地址。
*/
unsigned int mfree_page(struct mem_man* mm, unsigned int vir_addr_start) {
ASSERT((mm != NULL));
struct mem_man* mm0;
if(mm != &kernel_vir) {
mm0 = &user_phy;
} else {
mm0 = &kernel_phy;
}
unsigned int phy_addr_start = vaddr2paddr(vir_addr_start);
int i;
struct mem_desc* md = addr_free(mm0, phy_addr_start);
if(!md) {
return 0;
}
unsigned int t = vir_addr_start;
for(i = 0; i < md->size / 4096; i++) {
page_table_pte_remove(t);
t += 4096;
}
if(!md) {
return 0;
}
md = addr_free(mm, vir_addr_start);
return 1;
}
/*
初始化进程的虚拟地址空间,每个进程都有4G的虚拟地址空间,这里企图
将2G(含)后的虚拟地址归用户进程所有,但该函数疑似存在问题,这里不
做说明。
*/
void uservaddr_memman_init(struct mem_man* mm, unsigned int mem_start,
unsigned int mem_byte_size) {
ASSERT((mem_start >= 0x80000000));
mm->md_a = (struct mem_desc*)get_kernel_pages(10);
int i;
for(i = 0; i < 2000; i++) {
mm->md_a[i].status = -1;
}
double_linked_list_init(&mm->free);
double_linked_list_init(&mm->used);
mm->md_a[0].start = mem_start;
mm->md_a[0].size = mem_byte_size;
mm->md_a[0].status = 0;
double_linked_list_append(&mm->free, &mm->md_a[0].tag);
}
/*
一个未成熟的函数,这里未使用。
*/
void * get_a_page(unsigned int vaddr) {
lock_acquire(&user_phy.lock);
struct task* cur = running_thread();
struct mem_man* mm = &cur->user_vir;
if(cur->pgdir != NULL) {
struct list_node* t = mm->free.head.next;
struct mem_desc* md;
ASSERT((!double_linked_list_is_empty(&mm->free)));
while(t != &mm->free.tail) {
md = node2entry(struct mem_desc, tag, t);
if(md != NULL) {
break;
}
t = t->next;
}
if(t == &mm->free.tail) {
return NULL;
}
struct mem_desc* md0 = get_mem_desc(mm);
ASSERT((md0 != NULL));
if((double_linked_list_len(&mm->free) == 1) &&
(double_linked_list_len(&mm->used) == 0)) {
md0->start = vaddr;
md0->size = 2 * 4096;
md0->status = 1;
md->size -= 2 * 4096;
double_linked_list_append(&mm->used, &md0->tag);
}
}
unsigned int paddr = pg_alloc(&user_phy, 2);
page_table_add(vaddr, paddr);
lock_release(&user_phy.lock);
return (void*) vaddr;
}
(下面省略)
【kernel.c 节选】
(上面省略)
thread_start("k_thread_c", 2, k_thread_c, " argC ", 1);
/*
创建3个用户级进程,它们都运行在1的运行级上,且优先级为2(优先级
的设置归init_thread函数管)。
*/
process_execute(ua, "UA", 1);
process_execute(ub, "UB", 1);
process_execute(uc, "Uc", 1);
unsigned int keymap[0x80] = {
(中间省略)
void k_thread_c(void* arg) {
(中间省略)
while(1) {
printf_(" C %d ", ucv);
}
/*
*/
}
/*
三个进程的实体,它们仅仅是玩了命的自增三个不同的全局
变量。显示变量的工作由内核进程来完成。
*/
void ua(void) {
while(1) {
uav++;
/*
之所以这里没用,printf_打印变量信息,主要是因为它在几层
调用后含有特权指令,所以会出现一般保护异常。我们暂时选择
用全局变量在线程中显示。
*/
// printf_(" C %d ", ucv);
}
}
void ub(void) {
while(1) {
ubv++;
}
}
void uc(void) {
while(1) {
ucv++;
}
}
对于上面的代码,在看了注释后,笔者相信大家都能够理解。唯独需要捋清楚的问题是,到底线程和进程是如何自动切换的。这种无缝的切换技术是多么的巧夺天工。
第一个问题线程和进程从哪里开始。
不管线程或进程它们被处理器得以运行之前,都要有个“处女之身”,也就是“第一次”,这个第一次就非常的不容易哟。其实找到这个问题的答案并不难。我们只要提纲挈领就好了。不管是一段程序、一个线程还是一个进程要得以上处理器运行,指令指针寄存器的内容都必须要指向它们的开始。
那么到底是哪里更改了指令指针了呢?通过寻根溯源大家不难看出,当switch_to()这段汇编过程被schedule()函数调用时,我们仅仅是改变了位于任务结构首地址的self_kstack变量,它是一个无符号整形指针变量。这存放的是什么呢?为了高清楚,我们必须要看一下下面的代码:
;void switch_to(struct task* cur, struct task* next);
_switch_to:
pusha
mov eax, [esp + 9 * 4]
mov [eax], esp
mov eax, [esp + 10 * 4]
mov esp, [eax]
popa
ret
大家看到了吧,从调度函数一进到这个汇编过程,我们就搞了8个入栈操作(pusha),这也就是说此时的esp内容相对于switch_to()的位于栈中的返回地址处减少了8*4个字节长度(栈是向下增长的),所以通过esp + 8 * 4 + 1 * 4(这是返回地址所占的4字节,函数的调用就是call指令,它自动在栈中压入返回地址)也就是esp + 9 * 4也就找到了,而esp + 9 * 4正是调用switch_to(cur, next)的函数schedule()压入第一个参数cur的地方,下面的esp + 10 * 4便是next的地址。因此上mov eax, [esp + 9 * 4]语句的作用就是把cur的值放入eax中,也就是说eax就是cur,而cur是个什么东西呢?通过函数中参数变量的定义我们知道,它是前一个进程的进程控制块的指针,而mov [eax], esp是把当前esp,也就是pusha完成后的esp保存在cur的最开始处,也就是self_kstack的值。看来self_kstack只是保存switch_to()函数被调用又pusha后esp的值。那么接下来的我们就懂了吧,它是一个反过程,也就是把被选中线程next->self_kstack的值交还给esp,然后再把popa反操作出栈,最后再返回到调用他的函数schedule()中去。到此pusha和popa可能就是最难理解的了。其实它是最简单的梗。我们当中一定会有人认为,这两句中一个含有push esp,一个含有pop esp不是什么都没变嘛。那可就大错特错了,正是由于它们中间那几句,我们修改了esp的值,所以,pusha和popa竟然发生再不同的地方,弹出的esp值也肯定不同。这个希望大家一定谨记。其实大家一定会想,这个pusha和popa究竟发挥了什么作用呢?笔者经过不成熟的分析,认为即使没有它们,这里也不会出现任何错误。原因是switch_to()所操作的寄存器仅有esp和eax,esp固然是函数调用时约定被调用函数中保护的,然而,我们这里正是用的这个技巧才做得偷天换日的本领,何提保护二字。而eax作为函数返回值的代言人,也从来都不用保护。所以说,这里不用pusha和popa也可以,而且找cur和next的地址将更容易。上面我们说popa执行完成后就ret返回到schedule()了,其实是以偏概全了。唯一一种特殊情况就是我们那个第一次。由于我们事先在栈中安排了kernel_trhred(func, func_arg)的地址,作为ret的返回地址,所以会去执行kernel_thread()函数,此函数调用了开中断的函数intr_enable()函数调用,此函数可定会操作堆栈,但由于是平衡操作,都不会破坏高处的func和func_arg两个参数,所以下面可以正常调用func(func_arg),这个函数的调用也会产生返回地址,而当调用之前,esp指向9处,所以这里会残留一个返回地址,为什么说,这个地址用处不大(我们在线程中,处理缓冲区输入的循环放到了这里,其实满可以放在线程本身中),一般地,函数不应返回到这里。指导现在我们所说的真正的eip才得以露出阵容,它是被kernel_thread作为参数传递来的,由以函数调用的形式得以运行。请大家对照下面的结构看。
;struct thread_stack
;{
; 0 unsigned int edi;
; 1 unsigned int esi;
; 2 unsigned int ebp;
; 3 unsigned int esp;
; 4 unsigned int ebx;
; 5 unsigned int edx;
; 6 unsigned int ecx;
; 7 unsigned int eax;
; 8 unsigned int kernel_thread; 这里是返回地址。
; 9 unsigned int retaddr_dummy; 这里是cur。
; 10 unsigned int func; 这里是next。
; 11 unsigned int func_arg;
;};
把线程的这一过程弄清楚后,我们在来看看进程。首先我们要仔细看这三个函数。
void process_execute(void *filename, char* name, unsigned int level) {
(上面省略)
/*
使start_process作为被kernnel_thread函数调用的方法。并传递
真正要运行的进程地址filename。strat_process和filename都将
被放置在线程栈中。
*/
thread_create(thread, start_process, filename);
(下面省略)
void thread_create(struct task* pthread, thread_func function, void* func_arg) {
(上面省略)
kthread_stack->kernel_thread = (unsigned int)kernel_thread;
kthread_stack->func = (unsigned int)function;
kthread_stack->func_arg = (unsigned int)func_arg;
(下面省略)
void start_process(void* filename_) {
void* function = filename_;
struct task* cur = running_thread();
cur->self_kstack = (unsigned int*)((unsigned int)
cur->self_kstack + sizeof(struct thread_stack));
struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack;
proc_stack->edi = proc_stack->esi = proc_stack->ebp =
proc_stack->esp_dummy = proc_stack->ebx = proc_stack->edx =
proc_stack->ecx = proc_stack->eax = 0;
proc_stack->gs = proc_stack->fs = 0;
proc_stack->ds = proc_stack->es = 5 * 8 + 3;
proc_stack->eip = (unsigned int)function;
proc_stack->cs = 4 * 8 + 3;
proc_stack->eflags = 0x202;
proc_stack->esp = (unsigned int)get_user_pages(1) + 0x1000;
proc_stack->ss = 5 * 8 + 3;
asm volatile("movl %0, %%esp;jmp _exit"::"g"(proc_stack):"memory");
}
process_start和filename作为thread_create()的第二个和第三个参数传递进入该函数。而process_start和filename则分别被赋予kernel_thread(func, func_arg),从而引起函数调用process_start(filename)。当其被调用时,中断栈中,真正的处理器eip被赋予了filename(虽然几经变换,但难逃法眼金睛)的值。所以我们断定,最后一定是执行filename程序。至于start_process中其他的地方,大家是不是有种从无可奈何到似曾相识的感觉,没错,他就是我们上面展示调度iret任务切换方式的实验。几乎一摸一样,唯独不同的时,因为中断栈和中断退出代码形式上高度一致,这里直接调用了jmp_exit过程。