结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程 一,实验目标
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二,实验过程
1.首先了解下中断的过程
linux中具有中断门和系统门(相当于中断的描述符)总共有255个放在中断符描述表中,中断门包括段选择符用来到GDT中寻找对应的段描述符CS,段偏移用来描述中断服务程序的地址对应着eip的值,当中断发生时系统利用中断控制器读取的中断向量来找到对应的中断门从而找到中断服务程序的地址,在进入到中断服务例程之前首先控制单元完成一些列的操作:
- 确定中断向量。
- 利用中断向量在IDT中找到对应中断门,在中断门中得到段选择符从而可以从GDT中找到中断服务例程的段基址。
- 确定中断发生的特权级合法(linux只有内核态和用户态两种特权级,此步用来检查中断程序的特权是否低于引起中断的程序的特权,低优先级程序不能引起高优先级程序)
- 检查是否发生特权级变化(用户态陷入内核态,这时候需要设置内核的堆栈),如果发生读取当前程序的tss段(通过tr寄存器读取)来选择新特权级的ss和esp指针,然后保存旧的ss和esp指针。
- 若发生的是故障,用引起异常的指令地址修改cs 和eip寄存器的值,以使得这条指令在异常处理结束后能被再次执行。
- 在栈中保存eflags、cs和eip的内容。
- 如果异常产生一个硬件出错码,则将它保存在栈中。
- 装载cs和eip寄存器,其值分别是IDT表中第i项门 描述符的段选择符和偏移量字段。这对寄存器值 给出中断或者异常处理程序的第一条指定的逻辑 地址。
在控制单元完成上述一些列操作后就会跳转到中断的服务例程入口,IRQn_interrupt是中断的服务例程入口,主要是做所有中断都会做的一件事,调用SAVE_ALL保存上下文环境,SAVE_ALL会按照ptreg这个数据结构来以此保存寄存器,保存完内核栈结后(返回地址是do_IRQ()执行后的返回地址)在SAVE_ALL后会调用do_IRQ()函数来执行中断服务,同时还会把ptreg结构作为参数传递给do_IRQ()
2.其次,再了解下系统调用
linux32位系统利用int 0x80实现系统调用,和中断没有太大区别,首先也是有控制单元保存ss,esp,cs,eflags等寄存器,然后是跳转到系统调用服务函数system_call之处,在跳转之前还会利用eax(传递系统调用号),ebx(传递第一个参数),ecx(第二个参数)等等寄存器来传递参数(64位传递方式不同),在进入sys_call函数后会将系统调用号以及一些列的cpu寄存器压栈,并且验证系统调用号的合法性,如果合法最后执行call *sys_call_table(0,%eax,4)来找到具体的系统调用服务例程,系统维护了一个sys_call_table表来存储所有的系统调用服务程序。在执行完服务程序后就会按照正常中断返回。
3.fork函数
头文件:#include<unistd.h>,#include<sys/types.h>
函数原型:pid_t fork( void);
返回值: 若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1
函数说明:在Unix/Linux中用fork函数创建一个新的进程。进程是由当前已有进程调用fork函数创建,分叉的进程叫子进程,创建者叫父进程。该函数的特点是调用一次,返回两次,一次是在父进程,一次是在子进程。两次返回的区别是子进程的返回值为0,父进程的返回值是新子进程的ID。子进程与父进程继续并发运行。如果父进程继续创建更多的子进程,子进程之间是兄弟关系,同样子进程也可以创建自己的子进程,这样可以建立起定义关系的进程之间的一种层次关系。
不管系统使用clone(),vfork(),fork()最后都是调用do_fork()实现子程序的创建,首先会从内存分配一个8k的空间用来存储进程描述符和内核栈,拷贝父进程的描述符内容到新进程的描述符中,检查拥有当前进程的用户所拥有的进程最大数,如果进程使用了模块,则增加对应模块的引用计数,更新从父进程拷贝过来的一些标志,获得一个进程id号,更新一些不能从父进程继承的描述符的内容,建立进程的fs,files,mm,sighand结构并拷贝相应的父进程的内容,利用cpu寄存器中的值来初始化子进程的内核堆栈,把eax寄存器值为0(eax保存返回值),同时保存esp和eip的值。将子进程描述符插入到进程链表中,同时插入到进程运行链表中,这时候已完成大部分工作,当系统调用结束时会调用调度程序来决定何时调用子程序,同时调度程序调度子程序时会继续完善子程序,利用上面保存的esp,eip值来装载寄存器,同时进入到ret_from_sys_call()函数中,该函数是系统调用的返回程序,会利用子进程前面初始化好的内核栈来装在cpu寄存器(恢复现场),由于子进程和父进程执行的是同样的代码,所以当系统调用结束返回时检查eax寄存器中的返回值,如果是0就给子进程,如果是pid就给父进程,所以通常当fork一个新的子进程时我们可以通过返回值来判断当前进程是父进程还是子进程,所以我们可以在fork程序下面加上if语句在配合execv系统调用来装载我们需要执行的代码。
do_fork的部分代码如下:
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p;
int trace = 0;
long nr;
// ...
// 复制进程描述符,返回创建的task_struct的指针
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
if (!IS_ERR(p)) {
struct completion vfork;
struct pid *pid;
trace_sched_process_fork(current, p);
// 取出task结构体内的pid
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
// 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
// 将子进程添加到调度器的队列,使得子进程有机会获得CPU
wake_up_new_task(p);
// ...
// 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
// 保证子进程优先于父进程运行
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
特殊之处:
fork在陷?内核态之后有两次返回,第?次返回到原来的?进程的位置继续执?,但是在?进程中fork也返回了?次,会返回到?个特 定的点——ret_from_fork,所以它可以正常系统调?返回到?户态。4.execve函数
**函数定义:int execve(const char *filename, char const argv[ ], char const envp[ ]);
返回值:函数执行成功时没有返回值,执行失败时的返回值为-1.
函数说明:execve()用来执行参数filename字符串所代表的文件路径,第二个参数是利用数组指针来传递给执行文件,并且需要以空指针(NULL)结束,最后一个参数则为传递给执行文件的新环境变量数组。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。
Linux提供了execl、execlp、execle、execv、execvp和execve等六个用以执行一个可执行文件的函数(统称为exec函数,其间的差异在于对命令行参数和环境变量参数的传递方式不同)。这些函数的第一个参数都是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量。以上函数的本质都是调用在arch/i386/kernel/process.c文件中实现的系统调用sys_execve来执行一个可执行文件,该函数代码如下:
asmlinkage int sys_execve(struct pt_regs regs)
{
int error;
char * filename;
// 将可执行文件的名称装入到一个新分配的页面中
filename = getname((char __user *) regs.ebx);
error = PTR_ERR(filename);
if (IS_ERR(filename))
goto out;
// 执行可执行文件
error = do_execve(filename,
(char __user * __user *) regs.ecx,
(char __user * __user *) regs.edx,
®s);
if (error == 0) {
task_lock(current);
current->ptrace &= ~PT_DTRACE;
task_unlock(current);
/* Make sure we don‘t return using sysenter.. */
set_thread_flag(TIF_IRET);
}
putname(filename);
out:
return error;
}
该系统调用所需要的参数pt_regs在include/asm-i386/ptrace.h文件中定义:
struct pt_regs {
long ebx;
long ecx;
long edx;
long esi;
long edi;
long ebp;
long eax;
int xds;
int xes;
long orig_eax;
long eip;
int xcs;
long eflags;
long esp;
int xss;
};
execve系统调用的过程:
- execve系统调用陷入内核,并传入命令行参数和shell上下文环境
- execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文
- do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
- __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数
- search_binary_handler找到ELF文件解析函数load_elf_binary
- load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
- load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
- 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境
特殊之处:
当execve在调用时陷入内核态,就执行do_execve文件,覆盖当前的可执行程序,所以 返回的是新的可执行程序的起点。main函数位置是静态链接的可执行文件,动态链接的可执行文件需要连接动态链接库后在开始执行。5.Linux系统的一般执行过程
(1)正在运行的用户态进程X
(2)发生中断——save cs:eip/esp/eflags(current) to kernel stack,then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack).
(3)SAVE_ALL //保存现场
(4)中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换
(5)标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)
(6)restore_all //恢复现场
(7)iret - pop cs:eip/ss:esp/eflags from kernel stack
(8)继续运行用户态进程Y