引言
- 大家对上层应用程序中进程的使用应该不陌生,不过呢,本章节我们并不是介绍进程的使用,而是讲解操作系统中进程的具体实现
- 本章节中所涉及的任务和进程是一个意思,只是叫法上的不同
一个问题
- 计算机系统只有一个处理器,那么如何同时执行多个任务呢?
远古时期的计算机系统
- 如下图所示,远古时期,处理器一次只执行一个任务,看起来没什么问题,一个处理器一次就只能执行一个任务,但是深入思考,当处理器进行外部 IO 操作时,由于外部 IO 操作速度很慢,而 CPU 的速度又特别快,此时 CPU 只能闲置在那等着,其它任务几乎处于空闲状态,只能等待当前任务执行完毕,CPU 资源就造成很大的浪费
- 于是,人们自然的就想到,CPU 在进行 Task 1 的 IO 操作时,让处理器再去执行其它任务,多任务并行自然应运而生,当然,这里说的并行指的是宏观上的并行,从微观上看依旧是串行的,只是任务切换速度足够快,从人的感官上看是并行
任务的组成
- 从操作系统角度考虑,一个任务主要由 4 个部分组成:代码、数据、栈、状态(任务执行时各个寄存器的值)
工程添加
- 创建 "task.c" 文件,放到 "core" 文件夹下,并修改其下的 "BUILD.json", "src" 中添加 "task.c"
- 创建 "task.h" 文件,把下面实现的任务相关的结构体定义写到该文件中,放到 "include" 文件夹下
任务的表示
- 问题:如何在操作系统中表示一个任务呢?
- 用面向对象的思想就是抽象出一个任务类,用 C 语言就是实现一个任务结构体
typedef struct TASK { U32 id; // 任务 id U08* name; // 任务名称 U08* stack_addr; // 任务栈基址 U16 stack_size; // 任务栈大小 REG reg; // 任务上下文 } TASK;
- reg: 任务上下文,即任务执行状态下所有寄存器的状态,这是一个结构体,比如当程序从 Task A 切换到 Task B 执行时,先把 Task A 的执行上下文保存到该数据结构中,然后,将 Task B 中该数据结构的值恢复给寄存器,这样就切换到 Task B 中执行了(程序跟着 cs:eip 指向的位置执行,当 cs:eip 由指向 Task A 改为指向 Task B 后,那么程序自动由 Task A 切换到 Task B 执行)。reg 定义如下:
typedef struct REG { U32 gs; U32 fs; U32 es; U32 ds; U32 edi; U32 esi; U32 ebp; U32 kesp; U32 ebx; U32 edx; U32 ecx; U32 eax; U32 eip; U32 cs; U32 eflags; U32 esp; U32 ss; } REG;
为什么上下文指的只是寄存器状态
- 前面刚说过,一个任务主要由 4 个部分组成:代码、数据、栈、状态(寄存器),按理说上下文应该由这 4 个部分组成,为啥只有寄存器呢?
- 原因:代码、数据、栈这 3 部分都可以提前准备,每个任务都有自己独立的代码、数据、栈,唯独寄存器不能够独有,因为处理器只有一套寄存器
实验:执行第一个任务(进程)
- 其实在特权级相关章节中我们就已经实现了跳转到一个任务并执行,只不过那时候我们是用汇编实现
- 实验代码:loader.asm、task.c、task.h、main
- 参考以前实现过的代码 loader.asm 在当前的 loader.asm 添加任务状态段 TSS 和局部描述符表 LDT 相关代码(因为已经实现过,所以这里便不再重复讲解),只不过把 TASK A 的代码段转移到 kernel 中实现
执行第一个任务
- 利用恢复上下文来实现执行任务,那么如何恢复上下文呢?
如何恢复上下文
- 恢复上下文:根据 REG reg 数据结构中的值恢复到各个寄存器中
- 恢复上下文即恢复寄存器,那么如何恢复寄存器呢?
- 实现方式:首先将栈顶指针 esp 寄存器指向上下文数据结构 reg 的起始位置,把数据结构 reg 所处位置看成栈
- ① 处可以借助 pop 指令将数据弹出到对应得寄存器中
- ② 处其实也可以借助 pop 指令恢复寄存器,不过可以使用 popad 这一条命令替代【跳过 esp 寄存器(add esp, 4),放在这是因为入栈保存上下文时需要】
- ③ 处 几条指令恰巧就是 iret 指令本质,在 中断处理与特权级转移 中详细介绍过,回顾一下iret:
- 由此可见,REG reg 数据结构中各元素位置也是有一定顺序的,并不是随意排列的
- 为啥不用 mov gs, xxx 恢复寄存器的值而是使用 pop gs 这种形式呢?原因就是 mov 指令可能会改变 eflags 寄存器的值,而 pop 指令不会改变 eflags 的值
- 关键点:在恢复上下文的最后调用 iret 指令,iret 指令会修改 cs 和 eip 寄存器的值,当这两个寄存器改变之后,程序自动跳到 cs:eip 处执行,cs:eip 指向哪里,CPU 就跟着执行到哪里
- 有了前面的知识,我们就可以实现实现内核切换到第一个任务(进程)了
- 切换任务函数接口设计
- 函数名称: E_RET SwitchTo(TASK* task)
- 输入参数: TASK* task --任务指针
- 输出参数: 无
- 函数返回: E_OK:成功; E_ERR:失败
- 切换任务函数实现
E_RET SwitchTo(TASK* task) { // 取任务上下文(寄存器)基址 U32* reg_base = (U32*)(&task->reg); // 检查参数合法性 if(NULL == task || NULL == reg_base) return E_ERR; // 恢复上下文 asm volatile( "movl %0, %%esp\n" // 先将栈顶指针 esp 指向任务上下文 reg 的起始位置 "popl %%gs\n" "popl %%fs\n" "popl %%es\n" "popl %%ds\n" "popal\n" // popal(gcc) = popad(nasm) // "popl %%edi\n" // "popl %%esi\n" // "popl %%ebp\n" // "addl $4, %%esp\n" // 跳过 esp 寄存器本身的恢复 // "popl %%ebx\n" // "popl %%edx\n" // "popl %%ecx\n" // "popl %%eax\n" "iret\n" : : "r"(reg_base) // %0 替换成 reg_base : ); }
- 函数调用本身需要跳转地址再执行,函数调用(跳转)对处理器来说也是需要时间成本的,这涉及到 CPU 的 cache 缓冲机制,这里就不做讲解了。既然我们写的是操作系统,效率必须要考虑,可以用 C 语言宏定义的方式实现相同函数功能,宏在预编译阶段就会原地展开,这样就可以省去跳转的时间
#define SWITCH_TO(t) (void)({ \ U32* pBase = (U32*)(&(t)->reg); \ asm volatile( \ "movl %0, %%esp\n" \ "popl %%gs\n" \ "popl %%fs\n" \ "popl %%es\n" \ "popl %%ds\n" \ "popal\n" \ "iret\n" \ : \ : "r"(pBase) \ );})
- 接下来我们模拟一个任务(进程),然后调用任务切换函数,看看能够成功执行第一个任务(进程)
- 准备一个任务所需的相关原料
TASK task = {0}; // 任务对象 U08 task_stack[512]; // 任务私有栈 void TaskAFunc(void) // 任务执行函数 { printk("This is the first task.\n"); while (1); }
- 初始化填充任务对象,最后再调用 SWITCH_TO 跳转到任务中执行
task.id = 1; task.name = "Task A"; task.stack_addr = task_stack; task.stack_size = 512; task.reg.cs = LDT_CODE32_SELECTOR; task.reg.gs = LDT_VIDEO_SELECTOR; task.reg.ds = LDT_DATA32_SELECTOR; task.reg.es = LDT_DATA32_SELECTOR; task.reg.fs = LDT_DATA32_SELECTOR; task.reg.ss = LDT_DATA32_SELECTOR; task.reg.esp = (U32)task.stack_addr + task.stack_size; task.reg.eip = (U32)TaskAFunc; task.reg.eflags = 0x3002; // SwitchTo(&task); SWITCH_TO(&task);
- 上面初始化填充时所需的相关宏定义我们临时在 main.c 中定义一下吧
// 段选择符属性定义 #define SA_RPL0 0 // RPL = 0 #define SA_RPL1 1 // RPL = 1 #define SA_RPL2 2 // RPL = 2 #define SA_RPL3 3 // RPL = 3 #define SA_TIG 0 // TI = 0, GDT #define SA_TIL 4 // TI = 1, LDT // 局部段选择符定义 #define LDT_VIDEO_INDEX 0 #define LDT_CODE32_INDEX 1 #define LDT_DATA32_INDEX 2 #define LDT_VIDEO_SELECTOR ((LDT_VIDEO_INDEX << 3) + SA_TIL + SA_RPL3) #define LDT_CODE32_SELECTOR ((LDT_CODE32_INDEX << 3) + SA_TIL + SA_RPL3) #define LDT_DATA32_SELECTOR ((LDT_DATA32_INDEX << 3) + SA_TIL + SA_RPL3)
- 辛苦了那么久,最后运行效果怎么能不展示出来呢
- 验证一下当前是否执行在特权级 3 状态,是否使用私有任务栈
- 加两行代码,把私有任务栈的地址范围打印出来
printk("task stack start = %x\n",task_stack);
printk("task stack end = %x\n",task_stack+512);
- 编译运行,从打印信息可以看到私有站地址范围是 0xE0A0 ~ 0xE2A0,此时,按 Ctrl+C 键,使用 “reg” 指令,查看到 esp 值为 0xE294,该值在任务私有栈的范围内,使用 “sreg” 指令,查看 cs 的值为 0xf, bit0-bit1 的值为 3,说明此时程序运行在特权级 3 状态下
任务(进程)创建函数
- 上面的实验虽然取得了,但是初始化任务的工作我们也是临时实现的,现在我们就来把任务初始化填充工作封装到一个函数中吧
- 任务(进程)创建函数接口设计
- 函数名称: TaskCreat(TASK* task, TASK_FUNC pFunc, U08* stackAddr, U16 stackSize, U08* name)
- 输入参数: TASK_FUNC pFunc --任务函数; U08* stackAddr --任务栈基址; U16 stackSize --任务栈大小; U08* name --任务名称
- 输出参数: TASK* task --任务指针
- 函数返回: E_OK:成功; E_ERR:失败
- 具体的实现如下
E_RET TaskCreat(TASK* task, TASK_FUNC pFunc, U08* stackAddr, U16 stackSize, U08* name) { // 检查参数合法性 if(NULL == task || NULL == pFunc || NULL == stackAddr || 0 == stackSize) return E_ERR; task->name = name; task->stack_addr = stackAddr; task->stack_size = stackSize; task->reg.cs = LDT_CODE32_SELECTOR; task->reg.gs = LDT_VIDEO_SELECTOR; task->reg.ds = LDT_DATA32_SELECTOR; task->reg.es = LDT_DATA32_SELECTOR; task->reg.fs = LDT_DATA32_SELECTOR; task->reg.ss = LDT_DATA32_SELECTOR; task->reg.esp = (U32)task->stack_addr + task->stack_size; // 栈顶 task->reg.eip = (U32)pFunc; task->reg.eflags = 0x3002; // IOPL = 3, 允许任务(特权级 3)进行 /O 操作 return E_OK; }