一.实验目的
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二.实验过程
1.在分析fork和execve系统调用前我们首先来了解linux中断的具体过程,
linux中具有中断门和系统门(相当于中断的描述符)总共有255个放在中断符描述表中,中断门包括段选择符用来到GDT中寻找对应的段描述符,段偏移用来描述中断服务程序的地址对应着eip的值,当中断发生时系统利用中断控制器读取的中断向量来找到对应的中断门从而找到中断服务程序的地址,在进入到中断服务例程之前首先控制单元完成一些列的操作:
1)确定中断向量。
2)利用中断向量在IDT中找到对应中断门,在中断门中得到段选择符从而可以从GDT中找到中断服务例程的段基址。
3)确定中断发生的特权级合适(linux只有内核态和用户态两种特权级,此步用来检查中断程序的特权是否低于引起中断的程序的特权,低优先级程序不能引起高优先级程序)
4)检查是否发生特权级变化(用户态陷入内核态,这时候需要设置内核的堆栈),如果发生读取当前程序的tss段(通过tr寄存器读取)来选择新特权级的ss和esp指针,然后保存旧的ss和esp指针。
5)若发生的是故障,用引起异常的指令地址修改cs 和eip寄存器的值,以使得这条指令在异常处理结 束后能被再次执行。
6)在栈中保存eflags、cs和eip的内容。
7)如果异常产生一个硬件出错码,则将它保存在栈 中。
8)装载cs和eip寄存器,其值分别是IDT表中第i项门 描述符的段选择符和偏移量字段。这对寄存器值 给出中断或者异常处理程序的第一条指定的逻辑 地址。
在控制单元完成上述一些列操作后就会跳转到中断的服务例程入口,IRQn_interrupt是中段的服务例程入口,主要是做所有中断都会做的一件事,调用SAVE_ALL保存上下文环境,SAVE_ALL会按照ptreg这个数据结构来以此保存寄存器,保存完的内核栈结大致如下(返回地址是do_IRQ()执行后的返回地址)在SAVE_ALL后会调用do_IRQ()函数来执行中断服务,同时还会把ptreg结构作为参数传递给do_IRQ():
do_IRQ()函数会根据传递的参数IRQ来找到对应的irq_desc[]结构,这里就要讲一下irq_desc[]数组,这个结构是系统中断的主要结构,其中每个数组项指向一个irq_desc_t描述符,这个描述符包含:status(IRQ线的状态),handler(指向hw_interupt_type描述符,用来服务IRQ线等一些硬件的操作),action(具体的中断服务例程指向irqaction描述符),depth。其中hw_interrupt_type描述符之中有指向具体函数用来处理和中断控制器有关的操作,比如清除中断标志,重置电路等。irqaction描述符包括具体的中断要执行的程序,linux允许同时多个中断源在同一个IRQ线,这需要把对应的action注册到irqaction下,irqaction结构包括:handler(真正的中断服务程序),flag(不介绍),name(io设备名),dev_id(io设备的主设备号和次设备号),next(下一个irqaction)。do_IRQ()函数最后会执行irqaction中的action,在执行完后会检查中断下半部是否需要执行,然后跳转到上面栈保存的返回地址ret_from_intr()之处。
中断和异常或者系统调用的返回大致和进入中断相反,其主要过程如下图:
主要通过ret_from_intr,ret_from_exception,ret_from_sys_call,三个函数返回。
2.linux系统调用过程
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系统调用
不管系统使用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系统调用来装载我们需要执行的代码。
4.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;
};
该参数描述了在执行该系统调用时,用户态下的CPU寄存器在核心态的栈中的保存情况。通过这个参数,sys_execve可以获得保存在用户空间的以下信息:可执行文件路径的指针(regs.ebx中)、命令行参数的指针(regs.ecx中)和环境变量的指针(regs.edx中)。
真正执行程序的功能则是在fs/exec.c文件中的do_execve函数中实现的:
int do_execve(char * filename, char __user *__user *argv,
char __user *__user *envp, struct pt_regs * regs)
{
struct linux_binprm *bprm; // 保存和要执行的文件相关的数据
struct file *file;
int retval;
int i;
retval = -ENOMEM;
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_ret;
// 打开要执行的文件,并检查其有效性(这里的检查并不完备)
file = open_exec(filename);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_kfree;
// 在多处理器系统中才执行,用以分配负载最低的CPU来执行新程序
// 该函数在include/linux/sched.h文件中被定义如下:
// #ifdef CONFIG_SMP
// extern void sched_exec(void);
// #else
// #define sched_exec() {}
// #endif
sched_exec();
// 填充linux_binprm结构
bprm->p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *);
bprm->file = file;
bprm->filename = filename;
bprm->interp = filename;
bprm->mm = mm_alloc();
retval = -ENOMEM;
if (!bprm->mm)
goto out_file;
// 检查当前进程是否在使用LDT,如果是则给新进程分配一个LDT
retval = init_new_context(current, bprm->mm);
if (retval 0)
goto out_mm;
// 继续填充linux_binprm结构
bprm->argc = count(argv, bprm->p / sizeof(void *));
if ((retval = bprm->argc) 0)
goto out_mm;
bprm->envc = count(envp, bprm->p / sizeof(void *));
if ((retval = bprm->envc) 0)
goto out_mm;
retval = security_bprm_alloc(bprm);
if (retval)
goto out;
// 检查文件是否可以被执行,填充linux_binprm结构中的e_uid和e_gid项
// 使用可执行文件的前128个字节来填充linux_binprm结构中的buf项
retval = prepare_binprm(bprm);
if (retval 0)
goto out;
// 将文件名、环境变量和命令行参数拷贝到新分配的页面中
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval 0)
goto out;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval 0)
goto out;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval 0)
goto out;
// 查询能够处理该可执行文件格式的处理函数,并调用相应的load_library方法进行处理
retval = search_binary_handler(bprm,regs);
if (retval >= 0) {
free_arg_pages(bprm);
// 执行成功
security_bprm_free(bprm);
acct_update_integrals(current);
kfree(bprm);
return retval;
}
out:
// 发生错误,返回inode,并释放资源
for (i = 0 ; i MAX_ARG_PAGES ; i++) {
struct page * page = bprm->page;
if (page)
__free_page(page);
}
if (bprm->security)
security_bprm_free(bprm);
out_mm:
if (bprm->mm)
mmdrop(bprm->mm);
out_file:
if (bprm->file) {
allow_write_access(bprm->file);
fput(bprm->file);
}
out_kfree:
kfree(bprm);
out_ret:
return retval;
}
该函数用到了一个类型为linux_binprm的结构体来保存要要执行的文件相关的信息,该结构体在include/linux/binfmts.h文件中定义:
struct linux_binprm{
char buf[BINPRM_BUF_SIZE]; // 保存可执行文件的头128字节
struct page *page[MAX_ARG_PAGES];
struct mm_struct *mm;
unsigned long p; // 当前内存页最高地址
int sh_bang;
struct file * file; // 要执行的文件
int e_uid, e_gid; // 要执行的进程的有效用户ID和有效组ID
kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
void *security;
int argc, envc; // 命令行参数和环境变量数目
char * filename; // 要执行的文件的名称
char * interp; // 要执行的文件的真实名称,通常和filename相同
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
};
在该函数的最后,又调用了fs/exec.c文件中定义的search_binary_handler函数来查询能够处理相应可执行文件格式的处理器,并调用相应的load_library方法以启动进程。这里,用到了一个在include/linux/binfmts.h文件中定义的linux_binfmt结构体来保存处理相应格式的可执行文件的函数指针如下:
struct linux_binfmt {
struct linux_binfmt * next;
struct module *module;
// 加载一个新的进程
int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);
// 动态加载共享库
int (*load_shlib)(struct file *);
// 将当前进程的上下文保存在一个名为core的文件中
int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);
unsigned long min_coredump;
};
Linux内核允许用户通过调用在include/linux/binfmt.h文件中定义的register_binfmt和unregister_binfmt函数来添加和删除linux_binfmt结构体链表中的元素,以支持用户特定的可执行文件类型。
在调用特定的load_binary函数加载一定格式的可执行文件后,程序将返回到sys_execve函数中继续执行。该函数在完成最后几步的清理工作后,将会结束处理并返回到用户态中,最后,系统将会将CPU分配给新加载的程序。