操作系统习笔记(2) -- 内核初始化和进程运行和切换
前文:
操作系统习笔记(1) -- 一个操作系统的引导mp.weixin.qq.com![535521656e3c6dfe1986140082a19f0c.png](https://i-blog.csdnimg.cn/blog_migrate/08c37efea46e5d028d93e2563e358904.jpeg)
本文是操作系统学习笔记系列的第二章, 来研究内核的 init 进程的初始化和 进程的运行和切换. (内容太多了, 没看懂, 没能做到日更...
背景知识
crX 寄存器 控制寄存器
![c7129d876d03114e778af4a8a0203dd2.png](https://i-blog.csdnimg.cn/blog_migrate/383c0904350e87a13c7bbf4868f9d33a.jpeg)
用到的是这些:
- cr0 的 PG 位是表示是否开启分页 PE 表示是否保护模式, WP 是写保护, 是否允许 0 级程序向用户级程序的只读页面进行写操作, 别的是一些协处理器的控制位
- cr2 是当分页异常的时候, 把引起分页异常的线性地址放进去供操作系统处理
- cr3 是目录页表的起始地址. 不同的拥有独立虚拟空间的进程切换的时候, 要对这个做出修改.
分页
关于分页, 比较重要的是这张图:
![f947ee84bd60ae8b9d3071cce160834f.png](https://i-blog.csdnimg.cn/blog_migrate/e7dac59e25f6f612baa0ca1a744fb688.jpeg)
- cr3 指向了一个页目录, 不同进程有自己的页目录
- 每个页目录可以指向一个页表项目, 进程创建的时候, 要自己添加这些页表啊项目, 格式参考上图
调用约定
这是 c语言函数调用的时候的内存布局和寄存器值.
![15e895ae2eae36d6766a0eaf89a41925.png](https://i-blog.csdnimg.cn/blog_migrate/bcd864fc0ef006153c2fc829542c94af.jpeg)
esp 指向了栈帧的 return 地址
通过 pop 和 push 可以来操控 esp 的值,
- call 的时候, 会保存当前的eip 到当前栈帧的上 , 并且把 eip 指向函数开始的地方
- ret 的时候, 会根据 esp 指向的地址, 返回去
代码分析
初始化 init 进程
代码进入到 entry.S ,
- 先进行了一系列页表相关的配置:
- 操作 cr4 开启超级页功能
movl %cr4, %eax orl $(CR4_PSE), %eax movl %eax, %cr4 # 开启超级页
-
- 设置一个初始的页目录
movl $(V2P_WO(entrypgdir)), %eax movl %eax, %cr3
初始页面就是内核进程的页表 entrypgdir,
__attribute__((__aligned__(PGSIZE))) pde_t entrypgdir[NPDENTRIES] = { // Map VA's [0, 4MB) to PA's [0, 4MB) [0] = (0) | PTE_P | PTE_W | PTE_PS, // Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB) [KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS, };
代码写的抽象, 可以看出来是把内核进程的虚拟地址从 0-4mb 映射到 物理内存的 0-4m
-
- 开启分页 和 写保护
movl %cr0, %eax orl $(CR0_PG|CR0_WP), %eax # 开启分页 和写保护 movl %eax, %cr0
-
- 分配给 main 一个栈帧, 进入 c 语言处理
movl $(stack + KSTACKSIZE), %esp # 设置main 的栈 mov $main, %eax jmp *%eax .comm stack, KSTACKSIZE # 在 bss 区申请一个 stack s
esp 是栈顶指针寄存器, 把 esp 指向栈顶. 强行在内存这个位置进行main 的调用, 其实就是内核的代码段上面.
完成这个过程后, 内存就成了这个样子
![a3948eded5e66d3bfc2ab2caf92aa46b.png](https://i-blog.csdnimg.cn/blog_migrate/7a787d6f9b4f1b28a2cd7fc6e4e4925f.jpeg)
0-4m 映射过去, 并且stack 开始进行调用 main
- main 中 先进行了一些重要的操作, 比如内存分配器的初始化(kinit), 更精细的内核分页(kvmalloc), 不过我们不太关心这个, 直接看 inituser:
- 首先那一个新的进程结构, 和用连接器定义好的符号, 找到 initcode.S 的 代码地址和 大小.
struct proc *p; extern char _binary_initcode_start[], _binary_initcode_size[]; // 连接器定义的符号 p = allocproc(); - 然后初始化内存, 把虚拟空间 0 地址和 initcode.S的代码地址对应
if((p->pgdir = setupkvm()) == 0) // 申请一个内核页目录 .... inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
稍微跟进去看一下
void inituvm(pde_t *pgdir, char *init, uint sz) { char *mem; if(sz >= PGSIZE) panic("inituvm: more than a page"); mem = kalloc(); memset(mem, 0, PGSIZE); mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U); // 核心代码就是这一句 memmove(mem, init, sz); }
核心代码就是
mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U);
对应 虚拟空间 0地址的映射的页 - 接下来初始化内核栈保存点, trapframe, 系统调用的信息和执行过程会保存在这里.
memset(p->tf, 0, sizeof(*p->tf)); p->tf->cs = (SEG_UCODE << 3) | DPL_USER; p->tf->ds = (SEG_UDATA << 3) | DPL_USER; p->tf->es = p->tf->ds; p->tf->ss = p->tf->ds; p->tf->eflags = FL_IF; p->tf->esp = PGSIZE; p->tf->eip = 0; // beginning of initcode.S
先不管这里. - 最后让他运行
p->state = RUNNABLE;
这时候 init 进程就准备好运行了.
- 接下来 main 中, 开启调度, 开始了操作系统的主循环.
调用过程: main-> mpmain -> scheduler
开始调度运行所有进程. 不过我们先不管他怎么运行的, 我们先看看 init 里干了啥.
init 干了啥
进来以后先执行 initcode.S,
start:
pushl $argv
pushl $init
pushl $0 // where caller pc would be
movl $SYS_exec, %eax
int $T_SYSCALL
看的出是进行了一个系统调用, 执行了
exec("init", 0)
我们先不管系统调用之类的中断是怎么触发的, 可以先理解成一个事件模型.
看看 sys_exec 干了啥,
int
他处理完参数就调用 exec 了,
我们看看 exec:
- 首先 读取 elf 文件并分配一个页表:
if
- 给每个段用 allocuvm 分配一个映射的内存, 并且不断读取 elf
for
- 分配俩个页, 第一个clearpteu标记成不可用
sz
- 参数通过 ustack 放到 用户栈中
for
0 是main 函数的返回地址, 之后是参数, 这是因为栈帧的结构.
![fb9246ab4a8cd0d6893778e59695ed7e.png](https://i-blog.csdnimg.cn/blog_migrate/9d88725e6434b854d66fd4dbdd7fba5c.png)
- 接下来只要把刚刚从 elf 读到的信息, 构造的寄存器信息, main 入口的信息, 写到当前进程里就行
// Commit to the user image.
exec 就执行完了. 那他执行的 init 是干啥的呢? 我们看看 init.c
int
这个代码太简单了, 搞出一个子进程, 执行 sh, 程序后退出, 另一个作为一个僵尸进程一直后台死循环跑的. 这时候, 操作系统的初始化就完成了.
进程怎么切换
我们看看进程怎么切换, 找到: proc.c 的 scheduler,
- 找到一个状态为可运行的进程
for
- 设置当前cpu 运行的进程, 改变状态 和 cr3 寄存器指向的页目录
c
- 进入 switch, switch 的思路是把会改变的寄存器, 放到自己的栈帧上, 然后直接切换栈帧, 从之前保存好的栈帧里恢复寄存器的值, switch 代码可以分几个部分
- 解析出 switch 的参数 根据调用约定
movl 4(%esp), %eax // old
movl 8(%esp), %edx // new
- 保存当前寄存器的值到当前栈帧上
# Save old callee-saved registers
- 换栈, 换成 new
movl %esp, (%eax)
movl %edx, %esp
- 因为这时候 esp 寄存器的值变了, pop 的时候恢复的就是新的 esp 寄存器的位置, 根据保存的顺序,也就是 context 定义的顺序,
struct
我们连着 pop 4 次 , 把对应寄存器的值恢复, 此时, esp 就指向了 eip, 调用 ret, 就可以跳到 eip 执行了.
- 在别处 调用 sched方法, 可以恢复到内核的栈帧
void sched(void) {
....
swtch(&p->context, mycpu()->scheduler);
}
- 接着执行 scheduler里的 switchkvm 让cr3 寄存器指向内核的页目录
我们总结一下, 进程切换的关键是:
- 切换 cr3 寄存器所指向的页目录
- 把自己会变的信息放在一个栈帧上, 并把这个栈帧和 c 语言里定义的 context信息同步起来.
怎么执行到 main
我们发现, 初始化的进程的 context 的 eip 指向了 forket, main 是被 tf 所指向的, 那么怎么运行 tf 指向的 entry 呢?
答案在 proc.c 的 allocproc里:
sp
我们画一个 sp 的图
![182dad35270a3621bdad784f7be49b6b.png](https://i-blog.csdnimg.cn/blog_migrate/8cf4a2d785a8770dd89aa7c83e002b06.png)
很直观, forket 执行完了以后, 返回到的地方正好是trapret,
trapret的思路和 switch 像极了,
我们对应着 trapframe 的结构来看
// hardware and by trapasm.S, and passed to trap().
我们看 trapret
- 弹出通用寄存器
popal - 弹出 gs fs 用 l 来弹 一弹弹俩
popl %gs
popl %fs
popl %es
popl %ds
- 略过其他数据
addl $0x8, %esp # trapno and errcode
- esp 指向了 eip 了这时候 ret 一发 直接进入用户定义的程序中
iret
好了 , 至此我们已经搞懂了进程是怎么诞生和切换的了.