目录
【前言】
在李治军老师的《操作系统》课程的实验 5(基于内核栈切换的进程切换)中,需要对 fork() 系统调用进行修改,使得进程创建出能完成内核栈切换的样子,并实现父子进程共用同一个代码、数据和堆栈。
fork() 的核心是调用 copy_process(),所以主要是修改 copy_process 函数。copy_process 的代码不难获取,本文会对其中重要的语句进行分析,看一看修改后的 fork 创建的子进程如何实现进程的 “复刻” 以及切换,希望能帮助理解基于内核栈的进程创建(即子进程内核栈的初始化)。
copy_process 部分代码:
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
//用来申请一页内存空间作为子进程的PCB
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN; //申请失败
//申请成功
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
//子进程PCB部分内容初始化
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;
//容易看出下面的部分就是基于tss进程切换机制时的代码,所以要将此片段注释掉
/*
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
...
p->tss.trace_bitmap = 0x80000000;
*/
/** 从这里开始添加基于堆栈切换的代码(对fork的修改其实就是对子进程内核栈的初始化) **/
long *krnstack;
//p指针加上页面大小就是子进程的内核栈位置,所以krnstack指向子进程的内核栈
krnstack = (long)(PAGE_SIZE +(long)p);
//现在开始初始化子进程内核栈(krnstack)中的内容:
//下面的五句话可以完成上图所示的关联效果(父子进程共用同一内存、堆栈和数据代码块)
//因为下面的ss,esp,eflags,cs,eip这些参数就是来自调用copy_process()函数的进程的内核栈中
//也就是父进程的内核栈,所以下面5条指令就是将父进程内核栈中的前五个内容拷贝到子进程的内核栈中
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
*(--krnstack) = ds & 0xffff;
*(--krnstack) = es & 0xffff;
*(--krnstack) = fs & 0xffff;
*(--krnstack) = gs & 0xffff;
*(--krnstack) = esi;
*(--krnstack) = edi;
*(--krnstack) = edx;
//处理switch_to返回的位置,详细解释见下文
*(--krnstack) = (long) first_return_kernel;
//接下来的工作需要和switch_to接在一起考虑
//故事从哪里开始呢?回顾一下前面给出的switch_to,应该从“切换内核栈”完成的那个地方开始
//当时已经切换到子进程的内核栈开始工作,所以switch_to最后的四次弹栈以及ret使用的都是子进程内核栈中的东西
/* switch_to:
...
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
*/
//为了顺利完成上面4条弹栈工作,子进程的内核栈中应该有这些内容
//所以需要对子进程的krnstack进行相应初始化:(下面的ebp,ecx,ebx一样都来自父进程的内核栈)
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
//这里为啥是0而不继续继承父进程的eax呢?
//这里的eax就是进程的返回值,也是子进程和父进程的不同之处
//父进程返回子进程的pid,子进程返回0
//最后别忘了,由于我们初始化了子进程的内核栈,栈顶指针也变了
//所以要更新存放在PCB中对应的内核栈指针:
p->kernelstack = krnstack;
...
}
代码分析
接下来分析 copy_process 中一些关键的语句。
1. 申请子进程 PCB 空间
struct task_struct *p;
p = (struct task_struct *) get_free_page();
>> 申请一页内存空间作为子进程的 PCB,由指针 p 指向 <<
2. 找到子进程的内核栈
long *krnstack;
krnstack = (long)(PAGE_SIZE +(long)p);
>> p 指针加上页面大小就是子进程的内核栈位置,所以 krnstack 指向子进程的内核栈 <<
现在父进程和子进程的内核栈情况如下图所示,此时子进程内核栈空空如也(父进程通过 schedule() 中的 switch_to 被切换)。我们要做的就是向子进程内核栈中添加相应内容,使得子进程和父进程共用同一个代码、数据和堆栈。并且当子进程被调度时,通过 switch_to() 切换到子进程时能够实现基于内核栈的切换。
3. 父子进程共用同一内存、堆栈和数据代码块
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
>> 将 ss、esp、eflags、cs、eip 依次压入子进程的内核栈 <<
上面的 ss、esp、eflags、cs、eip 这些参数都是来自调用 copy_process() 函数的进程的内核栈 —— 也就是父进程的内核栈。所以以上 5 条指令就是将父进程内核栈中的前五个内容拷贝到子进程的内核栈中(Linux 0.11 内核栈的地址顺序从上往下,是由高到低的)。
压栈后父进程和子进程的内核栈情况如下图所示:
4. 相关寄存器入栈
*(--krnstack) = ds & 0xffff;
*(--krnstack) = es & 0xffff;
*(--krnstack) = fs & 0xffff;
*(--krnstack) = gs & 0xffff;
*(--krnstack) = esi;
*(--krnstack) = edi;
*(--krnstack) = edx;
>> ds、es、fs、gs……依次压入子进程的内核栈 <<
压栈后父进程和子进程的内核栈情况如下图所示:
为什么这里子进程压栈的内容和父进程栈中内容不太一样呢?因为父进程内核栈中 ds ~ eax 的压栈是通过 system_call 函数完成的(因为父进程通过系统调用进入内核),其中 reschedule 又将 ret_from_sys_call 函数的地址入栈。
而 ret_from_sys_call 函数又实现将之前由 system_call 保存的 eax ~ ds 依次恢复,并通过 iret 离开内核返回到用户程序。
但这里不用完全 copy 父进程的内核栈内容,我们也不通过 ret_from_sys_call 退出内核,而是自己编写一个函数 first_return_kernel 实现内核栈到用户栈的切换,效果是一样的。
5. switch_to 返回位置
*(--krnstack) = (long) first_return_kernel;
>> 将 first_return_kernel 函数的地址入栈 <<
这里的 first_return_kernel 作为 switch_to 的返回位置(具体解释见下文),入栈后内核栈情况如下图所示:
6. 响应 switch_to 的弹栈工作
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
>> ebp、ecx、ebx、0 依次入栈 <<
上面的工作需要和 switch_to 接在一起考虑。从哪里开始接呢?回顾 switch_to 的代码,应该从 “切换内核栈” 完成的地方开始。当时已经切换到下一个进程的内核栈开始工作,所以 switch_to 最后的四次弹栈以及 ret 指令使用的都是下一个进程内核栈中的东西。
// 进入新进程的内核栈工作,接下来的四次弹栈以及ret处理使用的都是新进程内核栈中的东西
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
当上面创建的子进程被 schedule() 调度,switch_to() 会依次将 PCB、内核栈、LDT 切换到子进程,接着就执行上面 5 条指令。为了顺利完成前 4 条弹栈工作,子进程的内核栈中应该有这些内容。所以需要对子进程的内核栈进行相应初始化(上面的 ebp、ecx、ebx 同样都来自父进程的内核栈),入栈后内核栈情况如下图所示。
ps:注意这里 *(--krnstack) = 0 而不是 = eax,也就是说 switch_to 进入子进程内核开始工作后执行 popl %eax 会得到 eax = 0。这里其实就是将内核栈中用于返回给 eax 寄存器的内容置为 0,而 eax 的内容会返回给 fork 函数,这就是为什么子进程的返回值为 0。
7. 更新子进程 PCB
p->kernelstack = krnstack;
>> 指针 p 指向子进程的 PCB,将其中的内核栈指针置为 krnstack <<
由于我们对子进程的内核栈进行了一系列初始化,栈顶指针也改变了,所以要更新存放在 PCB 中对应的内核栈指针。
8. 子进程切换流程
(1)现在子进程的内核栈已经初始化完成,栈内情况如下图所示:
(2)当上面的子进程被 schedule() 调度,switch_to() 会依次完成 PCB、内核栈、LDT 的切换。此时该子进程内核栈情况如下图所示:
(3)接着就是 switch_to 的最后 5 条指令:
// 现在进入新进程的内核栈工作了,所以接下来做的四次弹栈以及ret处理使用的都是新进程内核栈中的东西
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
① 前四次弹栈操作后,子进程内核栈变为:
② 最后一个 ret 指令。ret 就是从内核栈中弹出一个 32 位数作为 EIP 并跳转去执行。所以这里的 ret 会弹出 first_return_kernel 的地址并转去执行。
【 first_return_kernel 】
.align 2
first_return_kernel:
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret
可以看出 first_return_kernel 函数就是实现用户栈和用户代码的切换,依靠的核心指令是最后一句 iret。iret 之前是执行现场的恢复,主要是一些寄存器的恢复。这里的出栈内容和 copy_process 中入栈的内容是对应的。
③ 一系列出栈操作后,子进程内核栈如下图所示:
④ 最后的 iret 将推入堆栈的段地址和偏移地址弹出,使程序返回到原来发生中断的地方。而子进程内核栈中的 CS ~ SS 就是和父进程一样的内容,所以会切换到父进程的用户栈,并来到父进程用户程序停止时的位置继续往下执行。