Linux0.11系统调用之execve流程解析

Linux0.11系统调用之execve流程解析

  • 前言
  • execve功能介绍
  • execve本质
  • execve系统调用流程
  • 总结

前言

本文是基于Linux0.11源码来叙述该功能,源码可以在oldlinux.org上自行获取。

execve功能介绍

execve是用于运行用户程序(a.out)或shell脚本的函数,是linux编程中常用的一个系统调用类函数。在linux命令行下运行用户程序本质其实就是执行execve系统调用。

execve本质

在execve.c文件中execve被这样定义_syscall3(int,execve,const char *,file,char **,argv,char **,envp),其中_syscall3()是一个宏,将其展开后如下:

int execve(const char * file,char ** argv,char ** envp) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
	: "=a" (__res) \
	: "0" (__NR_execve),"b" ((long)(file)),"c" ((long)(argv)),"d" ((long)(envp))); \
if (__res>=0) \
	return (int) __res; \
errno=-__res; \
return -1; \
}

可以看到execve本质是系统调用int 0x80(类似于软中断的触发),系统调用号为__NR_execve赋值在eax当中,传入的参数分别为file、argv、envp由ebx、ecx、edx寄存器分别传入。
注:__NR_execve在unistd.h中定义,值为11,是sys_call_table的索引值(用于找到该表中对应的系统调用函数sys_execve)

execve系统调用流程

执行int 0x80后,CPU会跳转到_system_call执行,_system_call如下:

_system_call:
	cmpl $nr_system_calls-1,%eax #将系统调用号与系统调用max值对比
	ja bad_sys_call #如果超出范围,则跳转到bad_sys_call,是一个错误的系统调用号
	push %ds #用户数据端ds入栈,保护现场
	push %es #用户数据端ds入栈,保护现场
	push %fs #用户数据端ds入栈,保护现场
	pushl %edx
	pushl %ecx		
	pushl %ebx		# 将edx(file)、ecx(argv)、ebx(envp)入栈,作为C语言调用参数
	movl $0x10,%edx		# ds、es指向内核数据段
	mov %dx,%ds
	mov %dx,%es
	movl $0x17,%edx		# fs指向用户数据段(是内核与用户沟通的桥梁)
	mov %dx,%fs
	call _sys_call_table(,%eax,4) #调用sys_call_table表中第__NR_execve项函数,即sys_execve
	....

_system_call检查了系统调用号的正确性后保护了用户数据段的现场,根据eax即__NR_execve调用sys_call_table系统调用表中的sys_execve函数,sys_execve也是一个汇编函数如下:

_sys_execve:
	lea EIP(%esp),%eax #取堆栈中存放系统调用的返回地址的地址
	pushl %eax #将该地址入栈
	call _do_execve #调用do_execve函数
	addl $4,%esp #丢弃该地址
	ret

这边做了一个很重要的操作就是将堆栈中存放系统调用的返回地址的地址入栈(见下图中的PTR指针),注意这里是堆栈的地址!而非系统调用的返回地址(int 0x80 的下一句语句的地址)!!到这里,我们先观察一下当前的内核堆栈如下:
Linux0.11系统调用之execve流程解析_第1张图片

黄色部分:系统调用时CPU自动推入保护的参数,因为要陷入内核态,所以CS与SS、ESP需要被保存起来并置换成内核代码段、内核堆栈段、内核堆栈指针,保存EFLAGS现场,保存中断返回地址(系统调用返回地址)
蓝色部分:自_system_call起始代码推入堆栈的数据,其中倒数5个参数是接下来do_execve会调用的参数。

了解清楚堆栈后,接下来执行C函数do_execve的调用,do_excve函数如下(本文暂时先跳过shell的执行部分,先看看可执行文件如何被执行):

int do_execve(unsigned long * eip,long tmp,char * filename,
	char ** argv, char ** envp)//input : _system_call back addr, 
{
	struct m_inode * inode;
	struct buffer_head * bh;
	struct exec ex;
	unsigned long page[MAX_ARG_PAGES];//存放物理页地址,总共可以使用32个物理页用于存放参数
	int i,argc,envc;
	int e_uid, e_gid;
	int retval;
	int sh_bang = 0;//涉及shell,暂时先不看
	unsigned long p=PAGE_SIZE*MAX_ARG_PAGES-4;//指向[32个物理页的末端-4]地址

	if ((0xffff & eip[1]) != 0x000f)//eip指针指向堆栈图中黄色区域的EIP,那么eip[1]就是用户代码段CS
		panic("execve called from supervisor mode");//如果CS指向内核数据段则宕机,不允许内核使用
	for (i=0 ; ii_mode)) {	//查看可执行文件的inode是否是常规文件,不是则发生错误
		retval = -EACCES;
		goto exec_error2;
	}
	i = inode->i_mode;
	e_uid = (i & S_ISUID) ? inode->i_uid : current->euid;//如果S_ISUID置位,那么有效的UID就是inode的uid,否则沿用当前进程的uid
	e_gid = (i & S_ISGID) ? inode->i_gid : current->egid;//如果S_ISGID置位,那么有效的GID就是inode的gid,否则沿用当前进程的gid
	if (current->euid == inode->i_uid)//如果进程id与文件的user id一致,那么使用user id
		i >>= 6;
	else if (current->egid == inode->i_gid)//如果进程id与文件的group id一致,那么使用group id
		i >>= 3;
	if (!(i & 1) &&
	    !((inode->i_mode & 0111) && suser())) {//判断是否拥有执行权限或是否超级用户
		retval = -ENOEXEC;//没有执行权限,输入返回值ENOEXEC
		goto exec_error2;//跳转到错误处理位置
	}
	if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {//根据inode读取文件的第一个block,block号存放在inode->i_zone[0]内,读哪个设备由inode->i_dev来定
		retval = -EACCES;//读不到数据则返回错误EACCES
		goto exec_error2;//跳转到错误处理位置
	}
	ex = *((struct exec *) bh->b_data);	//读取exec头部
	if ((bh->b_data[0] == '#') && (bh->b_data[1] == '!') && (!sh_bang)) {//如果文件开头是#!开头,则可能是shell脚本
		......//这里是shell的处理,本篇先不做赘述
	}
	brelse(bh);//释放buffer head,因为已经得到了exec头部(第一个block中只有exec头部信息有效),所以释放
	if (N_MAGIC(ex) != ZMAGIC || ex.a_trsize || ex.a_drsize ||
		ex.a_text+ex.a_data+ex.a_bss>0x3000000 ||
		inode->i_size < ex.a_text+ex.a_data+ex.a_syms+N_TXTOFF(ex)) {//检查exec头魔数是否为ZMAGIC,a_trsize与a_drsize是否为零,代码数据长度不得大于48M,inode的i_size不可小于代码段大小+数据段大小+符号表大小+exec头部占用大小
		retval = -ENOEXEC;//否则返回错误值ENOEXEC
		goto exec_error2;
	}
	if (N_TXTOFF(ex) != BLOCK_SIZE) {//exec头部必须占有BLOCK_SIZE大小
		printk("%s: N_TXTOFF != BLOCK_SIZE. See a.out.h.", filename);
		retval = -ENOEXEC;
		goto exec_error2;
	}
	if (!sh_bang) {//不是shell
		p = copy_strings(envc,envp,page,p,0);//拷贝参数到page当中(里面会分配物理页)
		p = copy_strings(argc,argv,page,p,0);//拷贝环境变量到page当中(里面会分配物理页)
		if (!p) {
			retval = -ENOMEM;
			goto exec_error2;
		}
	}
	if (current->executable)//如果当前进程是可执行文件
		iput(current->executable);//那么释放当前的可执行文件inode节点
	current->executable = inode;//当前可执行文件inode赋值为最新
	for (i=0 ; i<32 ; i++)//信号处理函数全部清空
		current->sigaction[i].sa_handler = NULL;
	for (i=0 ; iclose_on_exec>>i)&1)
			sys_close(i);
	current->close_on_exec = 0;
	free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));//清空当前进程的页表映射
	free_page_tables(get_base(current->ldt[2]),get_limit(0x17));//清空当前进程的页表映射
	if (last_task_used_math == current)
		last_task_used_math = NULL;
	current->used_math = 0;
	p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;//改变ldt
	p = (unsigned long) create_tables((char *)p,argc,envc);//制作参数表(类似指针数组)
	current->brk = ex.a_bss +
		(current->end_data = ex.a_data +
		(current->end_code = ex.a_text));//写入代码结束位置、数据结束位置、bss结束位置
	current->start_stack = p & 0xfffff000;//记录堆栈指针所在页
	current->euid = e_uid;
	current->egid = e_gid;
	i = ex.a_text+ex.a_data;
	while (i&0xfff)//如果数据末端不是页对齐(4KB对齐),那么将数据末端到页末端的数据清零
		put_fs_byte(0,(char *) (i++));//我感觉这里会触发缺页异常,因为page_table被free了,后来发现 a_text+a_data几乎就4kb对齐了,好像进不来这
	eip[0] = ex.a_entry;		//上图堆栈图中黄色部分EIP系统调用返回地址被替换成用户程序入口地址
	eip[3] = p;			//上图堆栈图中黄色部分ESP用户堆栈指针被替换成p
	return 0;//返回0
exec_error2:
	iput(inode);
exec_error1:
	for (i=0 ; i

上述代码如果看不懂,继续往下看分析,接下来截取关键逻辑进行解析:

  1. 首先filename是可执行文件,我们必须读取可执行文件(a.out)在磁盘中的数据才可以运行,那么我们要找到其在硬盘中的位置,需要依托inode,因此我们使用
    inode=namei(filename)读取到filename的inode节点(这是根据根目录inode或者当前目录inode一路索引找到)。

  2. 获取到了可执行文件的inode节点,使用bh = bread(inode->i_dev,inode->i_zone[0])块设备读取函数读取其第一块数据,其中i_dev是设备号(从哪个块设备读取),i_zone[0]是逻辑块号(读取块设备中的哪一个部分)。读到了可执行文件的首个block(1KB)后,ex = *((struct exec *) bh->b_data)将exec头部读取出来,第一个block中只有exec头部的数据是有效的,用于记录可执行文件的一些信息,exec头部结构体struct exec如下所示:

    struct exec {
      unsigned long a_magic;	/* Use macros N_MAGIC, etc for access */
      unsigned a_text;		/* length of text, in bytes */
      unsigned a_data;		/* length of data, in bytes */
      unsigned a_bss;		/* length of uninitialized data area for file, in bytes */
      unsigned a_syms;		/* length of symbol table data in file, in bytes */
      unsigned a_entry;		/* start address */
      unsigned a_trsize;		/* length of relocation info for text, in bytes */
      unsigned a_drsize;		/* length of relocation info for data, in bytes */
    };
    

    由图可见,exec头部包含了二进制文件的代码长度、数据长度、bss段长度、符号表长度、起始地址、数据代码重定位信息(这个没用到)。

  3. 有了可执行文件的基本数据,接下来要准备运行的环境,需要修改ldt局部描述符表,否则代码数据可能会无法被访问到,因为有限长,并且需要先把当前进程的页表映射全部切断。使用free_page_tables(get_base(current->ldt[1]),get_limit(0x0f))free_page_tables(get_base(current->ldt[2]),get_limit(0x17))分别切断代码段和数据段的页表映射。使用change_ldt(ex.a_text,page)函数修改ldt局部描述符表,将代码段起始与数据段起始一致均从0开始,代码段限长修改为ex.a_text的长度,数据段限长修改为0x4000000,可以访问到整个进程内的所有数据,因为一个进程的空间为64M,这里限长正好就是64M。代码片段如下:

    static unsigned long change_ldt(unsigned long text_size,unsigned long * page)
    {
    	unsigned long code_limit,data_limit,code_base,data_base;
    	int i;
    
    	code_limit = text_size+PAGE_SIZE -1;//计算代码段所占页面
    	code_limit &= 0xFFFFF000;//页对齐
    	data_limit = 0x4000000;//64M
    	code_base = get_base(current->ldt[1]);//获取代码段起始
    	data_base = code_base;//数据段与代码段起始一致,这里均为0
    	set_base(current->ldt[1],code_base);//设置代码段起始
    	set_limit(current->ldt[1],code_limit);//设置代码段限长
    	set_base(current->ldt[2],data_base);//设置数据段起始
    	set_limit(current->ldt[2],data_limit);//设置数据段限长
    /* make sure fs points to the NEW data segment */
    	__asm__("pushl $0x17\n\tpop %%fs"::);//fs赋值0x17代表指向用户数据段
    	data_base += data_limit;//指向末端
    	for (i=MAX_ARG_PAGES-1 ; i>=0 ; i--) {//这里是填充参数页,将存有参数的物理页填入页表形成映射
    		data_base -= PAGE_SIZE;
    		if (page[i])
    			put_page(page[i],data_base);
    	}
    	return data_limit;
    }
    
  4. 环境的准备了解到了,那么得考虑一下参数如何传入,argv参数与env环境变量如何传入到用户程序当中。系统预留了32个页(4KB*32)的空间用于存放参数及环境变量,使用p作为空间的索引(类似堆栈指针,向下生长),p初始指向32页空间的最后4bytes位置(unsigned long p=PAGE_SIZE*MAX_ARG_PAGES-4),copy_strings(envc,envp,page,p,0)copy_strings(argc,argv,page,p,0)将参数及环境变量如同堆栈一般,从上往下压入32页空间,如果未分配则分配物理页,代码如下:

    static unsigned long copy_strings(int argc,char ** argv,unsigned long *page,
    		unsigned long p, int from_kmem)
    {
    	char *tmp, *pag;
    	int len, offset = 0;
    	unsigned long old_fs, new_fs;
    
    	if (!p)
    		return 0;	/* bullet-proofing */
    		
    	while (argc-- > 0) {//argc是参数的个数
    		if (!(tmp = (char *)get_fs_long(((unsigned long *)argv)+argc)))//指向第argc个参数,如果为空,宕机
    			panic("argc is wrong");
    			
    		len=0;		/* remember zero-padding */
    		do {
    			len++;
    		} while (get_fs_byte(tmp++));//计算参数长度,即字符串长度,以‘\0’结尾
    		if (p-len < 0) {	 //确认32页空间(32*4KB=128KB)是否无法容纳新的参数
    			return 0;
    		}
    		while (len) {//循环参数长度(一个一个字节存入)
    			--p; --tmp; --len;//p指向新的一个字节空间,tmp指向待复制的参数尾端,len代表剩余长度
    			if (--offset < 0) {//页内偏移小于0
    				offset = p % PAGE_SIZE;//重置页内偏移
    				if (!(pag = (char *) page[p/PAGE_SIZE]) &&
    				    !(pag = (char *) page[p/PAGE_SIZE] =
    				      (unsigned long *) get_free_page())) //如果该页不存在则分配
    					return 0;
    			}
    			*(pag + offset) = get_fs_byte(tmp);//从用户空间将一字节复制到物理页内
    		}
    	}
    	return p;
    }
    

    后文将参数与环境变量统称为参数。以上代码经过轻微删减,因为from_kmem为0,即我们要复制的参数都是从用户空间而来,通过代码可以感受到,参数变量从用户空间被复制,并被存入32页的参数空间p指向的位置(从上往下),参数变量的实质就是字符串,复制时每个字符串的尾末’\0’也会被复制,用以分割每个参数。执行完复制后,参数页的逻辑空间如下图所示(假设有2个参数2个环境变量):
    Linux0.11系统调用之execve流程解析_第2张图片

  5. 参数占用多少物理页,就进行多少页的映射,页表映射的操作在change_ldt函数的后半部分(前半部代码上面解释过,此处省略掉),预留的32页参数页从最后一页开始,将参数占用的页put_page(),放到64M(Linux0.11中每个进程的逻辑空间为64M)末端,建立映射,代码如下:

     static unsigned long change_ldt(unsigned long text_size,unsigned long * page)
     {
     	unsigned long code_limit,data_limit,code_base,data_base;
     	int i;
     	.....//省略了其他代码
     	data_base += data_limit;//指向末端
     	for (i=MAX_ARG_PAGES-1 ; i>=0 ; i--) {//这里是填充参数页,将存有参数的物理页填入页表形成映射,用了多少页就映射多少页
     		data_base -= PAGE_SIZE;//指向还未使用的最后一页(逻辑页)
     		if (page[i])//物理页存在(则表示该页负载了参数或环境变量)
     			put_page(page[i],data_base);//将物理页与逻辑页形成映射
     	}
     	return data_limit;
     }
    
  6. 制作环境变量、参数指针表,由上可见参数、环境变量仅仅是被拷贝过去并且实现页表映射,但参数、环境变量使用起来不是很方便且参数与环境变量没有明确的分界(就算根据p读出字符串,但也不知道该字符串是环境变量还是参数),因此使用create_tables((char *)p,argc,envc)进行指针表的制作。

    static unsigned long * create_tables(char * p,int argc,int envc)
    {
    	unsigned long *argv,*envp;
    	unsigned long * sp;
    
    	sp = (unsigned long *) (0xfffffffc & (unsigned long) p);//4byte对齐
    	sp -= envc+1;//留出 环境变量数+1 个指针空间
    	envp = sp;//记录环境变量指针空间首地址
    	sp -= argc+1;//留出 参数个数+1 个指针空间
    	argv = sp;//记录参数指针空间首地址
    	put_fs_long((unsigned long)envp,--sp);//存入环境变量指针空间首地址
    	put_fs_long((unsigned long)argv,--sp);//存入参数指针空间首地址
    	put_fs_long((unsigned long)argc,--sp);//存入参数的个数
    	while (argc-->0) {
    		put_fs_long((unsigned long) p,argv++);//将各个参数的首地址依次放入参数指针空间
    		while (get_fs_byte(p++)) ;//读取下一个参数的首地址(因为是字符串,所有以0作为分隔)
    	}
    	put_fs_long(0,argv);//指针空间必须以NULL结尾
    	while (envc-->0) {//同理放入环境变量
    		put_fs_long((unsigned long) p,envp++);
    		while (get_fs_byte(p++)) /* nothing */ ;
    	}
    	put_fs_long(0,envp);//指针空间必须以NULL结尾
    	return sp;
    }
    

    指针表制作完毕后,32页的参数空间如下所示(假设只有2个参数、2个环境变量):
    Linux0.11系统调用之execve流程解析_第3张图片
    如上图所示,可以看到形成了2个指针数组(指针表),如果写成C语言则是,分别是unsigned int *arg0_ptr_ptr[] = {arg0_ptr, arg1_ptr, NULL}unsigned int *env0_ptr_ptr[] = {env0_ptr, env1_ptr, NULL},那么我们可以想象到可执行文件a.out中的main()函数传入的参数argc,argv就是图中的int argcint **arg0_ptr_ptr

  7. 修改跳转地址及用户堆栈,使系统调用返回地址改为可执行文件的进入地址(在Linux0.11中为0),修改eip[0] = ex.a_entry;eip[3] = p;就这么简单,把系统调用(中断)返回地址修改成了用户程序的入口地址,用户堆栈修改成了p。到了这里,堆栈的空间如下图所示(eip就是下图中的PTR,因此不难推算出eip[0]和eip[3]修改的是堆栈中的哪几个参数):
    Linux0.11系统调用之execve流程解析_第4张图片
    由图可见,红框内是被修改的值,可以参照上面未修改的堆栈图作对比,那么当系统调用返回时,CPU指令指针的指向是ex.a_entry,堆栈是p

  8. 至此,execve的系统调用基本完成,肯定有人有疑惑,当前进程的页表也仅仅只映射了参数区块的页表,代码没拷贝没映射,系统调用(中断)返回时不会产生异常么?是的,确实会产生异常,当执行到了可执行文件a.out未被拷贝入物理内存的指令或数据时,会产生缺页异常(异常发生的原因是页表present值为0,未映射,不存在),缺页异常会将a.out的代码拷贝入新分配的物理内存页而后进行逻辑地址与物理地址的映射,即填充页表,异常处理结束后返回发生异常的地址重新执行代码。因此,所谓的用户程序(包括你平时玩的游戏)在运行时,并不一定整个游戏的代码就在你的运行内存当中,事实是访问到了才被拷贝入运行内存(按页拷贝,4KB)。
    参考文章: Linux0.11系统异常之页异常

  9. 那么看看当前进程的64M逻辑地址分布,如下图所示:
    Linux0.11系统调用之execve流程解析_第5张图片
    由图可以很明晰得看到,参数、环境变量被置于64M顶端,p紧贴其后,代码段从nr*-0x04000000开始(nr是任务结构体的索引值),当然访问代码段的时候从0地址开始即可,因为CPU在保护模式下的寻址是会加上段基址(段基址是存储在ldt当中的,图中段基址nr*0x04000000),所以编译应用程序的时候都是从0地址开始,包括ex.a_entry值也是0。上图中的蓝色区域目前是不存在的,都是应用程序执行后才可能出现的,堆栈与堆相向而行,但空间足够大,基本不会碰着面。

总结

evecve系统调用将当前进程重新划分,为可执行文件划出合理的空间,并将参数置于当前进程64M末端,为应用程序的执行做好了准备。

你可能感兴趣的:(Old,Linux,Linux内核,linux,运维,服务器)