从整体上理解进程创建、可执行文件的加载和进程执行进程切换

学号129,原创作品转载请注明出处
实验内容来源 : https://github.com/mengning/linuxkernel/

实验要求

  • 阅读理解task_struct数据结构 http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235;
  • 分析fork函数对应的内核处理过程do_fork,理解创建一个新进程如何创建和修改task_struct数据结构;
  • 使用gdb跟踪分析一个fork系统调用内核处理函数do_fork ,验证您对Linux系统创建一个新进程的理解,特别关注新进程是从哪里开始执行的?为什么从那里能顺利执行下去?即执行起点与内核堆栈如何保证一致。
  • 理解编译链接的过程和ELF可执行文件格式;
  • 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接;
  • 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解;
  • 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
  • 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
  • 使用gdb跟踪分析一个schedule()函数 ,验证您对Linux系统进程调度与进程切换过程的理解;
  • 特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系;

实验过程

一.阅读理解task——struct数据结构

首先我们要理解什么是进程。进程是正在执行的程序的一个执行实例,它由系统分配处理器等资源并由处理器执行。为了对进程进行管理,操作系统必须对进程所做的事情进行清晰的描述,操作系统中通常使用数据结构来代表不同实体,这就是所谓的PCB(进程控制块)。
http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235
打开代码链接在1235行我们看到定义,其中重要参数有

volatile long state;	//进程当前状态
unsigned int flags;		//进程标志,用于内核识别以备下一步操作
pid_t pid;
pid_t tpid;				//进程或线程的标识符
long priority;  		//进程优先级
long counter;  			//在调度算法中表示进程还可运行多久
unsigned long policy;  	//该进程的进程调度策略

此外,在PCB中还定义了进程创建时的父子兄弟关系(以指针的形式),以及进程的底层信息,相关tty设备,ptrace系统调用等大量的参数信息。

二.分析fork函数及对应的内核处理过程do_fork

查阅源代码可以看到sys_clonesys_forksys_vfork这三个系统调用的实现函数都指向了位于/kernel/fork.c中的_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,
	      unsigned long tls)
{
	struct completion vfork;
	struct pid *pid;
	struct task_struct *p;
	int trace = 0;
	long nr;

	//以下部分是确定是否向ptracer发送报告
	if (!(clone_flags & CLONE_UNTRACED)) {
		if (clone_flags & CLONE_VFORK)
			trace = PTRACE_EVENT_VFORK;
		else if ((clone_flags & CSIGNAL) != SIGCHLD)
			trace = PTRACE_EVENT_CLONE;
		else
			trace = PTRACE_EVENT_FORK;

		if (likely(!ptrace_event_enabled(current, trace)))
			trace = 0;
	}
	
	/*调用copy_process函数,复制进程描述符,并创建子进程所需的所有其他的数据结构,返回指向该PBD的指针*/
	p = copy_process(clone_flags, stack_start, stack_size,
			 child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
	add_latent_entropy();

	if (IS_ERR(p))
		return PTR_ERR(p);
	trace_sched_process_fork(current, p);
	
	//获取创建的子进程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);
	}
	//子进程加入调度队列中
	wake_up_new_task(p);

	/*fork完成,子进程开始运行,并报告给ptracer*/
	if (unlikely(trace))
		ptrace_event_pid(trace, pid);

	if (clone_flags & CLONE_VFORK) {
		if (!wait_for_vfork_done(p, &vfork))
			ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
	}

	put_pid(pid);
	return nr;
}

具体流程如下:

  • fork, vforkclone的系统调用定义是依赖于体系结构的, 因为在用户空间和内核空间之间传递参数的方法因体系结构而异,但他们都调用体系结构无关的_do_fork(或者早期的do_fork)函数, 负责进程的复制。
  • _do_fork以调用copy_process开始, 后者执行生成新的进程的实际工作, 并根据指定的标志复制父进程的数据。在子进程生成后, 内核必须执行一些收尾操作,如复制进程信息,子进程加入调度器等。
  • copy_process流程:调用 dup_task_struct复制当前的task_struct->检查进程数限制并初始化CPU 定时器等信息->调用 sched_fork 初始化进程数据结构,并把进程状态设置为 TASK_RUNNING->复制所以进程信息并调用copy_thread_tls初始化子进程内核栈->为新进程分配设置新的pid

三.使用gdb跟踪分析一个fork系统调用过程

实验环境是ubuntu18.04
打开上次实验完成编译的内核,启动menuos

qemu-system-x86_64 -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd roootfs.img

在这里插入图片描述
进入gdb调试

gdb
file LinuxKernel/linux-5.0.1/vmlinux
target remote:1234

设置断点
在这里插入图片描述
运行后先是在sys_clone->_do_fork->copy_process
在这里插入图片描述
之后进入dup_task_struct,复制父进程PCB信息
在这里插入图片描述
再进入copy_thread_tls,创建子进程,此时通过指令p *p查看该进程指针指向的进程的信息
在这里插入图片描述
可以看到该子进程的一些参数。
最后进入ret_from_fork,完成进程的fork,退出
在这里插入图片描述新进程是从ret_from_fork开始执行,根据copy_process中的copy_thread_tlsret_from_fork

int copy_thread(unsigned long clone_flags, unsigned long sp, 
	unsigned long arg, struct task_struct *p) 
{
	... 
	*childregs = *current_pt_regs(); 
	childregs->ax = 0; 
	if (sp) childregs->sp = sp; 
	p->thread.ip = (unsigned long) ret_from_fork; 
	... 
}

ENTRY(ret_from_fork)
	CFI_STARTPROC
	pushl_cfi %eax
	call schedule_tail
	GET_THREAD_INFO(%ebp)
	popl_cfi %eax
	pushl_cfi $0x0202       # Reset kernel eflags
	popfl_cfi
	jmp syscall_exit
	CFI_ENDPROC
END(ret_from_fork)

ret_from_fork之前,也就是在copy_thread函数中:
*childregs = *current_pt_regs();
该句将父进程的regs参数赋值到子进程的内核堆栈,*childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数。故在之后的RESTORE ALL中能顺利执行下去,从而保证执行起点与内核堆栈一致。

四.理解编译链接的过程和ELF可执行文件格式在这里插入图片描述

  • ELF可执行文件格式,在计算机科学中,是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface,ABI)而开发和发布的,也是Linux的主要可执行文件格式。
  • ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且他们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
  • 静态链接:在编译链接时直接将需要的执行代码复制到最终可执行文件中,有点是代码的装在速度块,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对较大。
  • 动态链接:动态链接是在程序运行时由操作系统将需要的动态库加载到内存中。动态链接分为装载时动态链接和运行时动态链接。
  • 整体函数调用关系: sys_execve()->do_execve()->do_execveat_common()->__do_execve_file() ->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread()
    使用gdb跟踪调试看以看到其中部分函数调用的路径

在这里插入图片描述在这里插入图片描述
其中的do_execve函数原型如下

int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

五.几个关于可执行程序的问题

新的可执行程序是从哪里开始执行的?为什么sys_execve返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?

  • 新的可执行程序通过修改内核堆栈eip作为新程序的起点,从new_ip开始执行后start_thread把返回到用户态的位置从int 0x80的下一条指令变成新加载的可执行文件的入口位置。
  • 当执行到sys_execve时,进入内核态,用sys_execve()加载的可执行文件覆盖当前进程的可执行程序,当sys_execve返回时,返回新的可执行程序的执行起点(main函数),所以sys_execve返回后新的可执行程序能顺利执行。
  • sys_execve返回时,如果是静态链接,elf_entry指向可执行文件规定的头部(main函数对应的位置0x8048***);如果需要依赖动态链接库,elf_entry指向动态链接器的起点;动态链接主要是由动态链接器ld来完成的。

六.理解Linux系统中的进程调度的时机和schedule函数,并用gdb跟踪分析

调度时机:

  • 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
  • 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度
  • 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。

gdb跟踪调用过程如下:
在这里插入图片描述在这里插入图片描述
其中两个重要函数pick_next_taskcontext_swtich都在函数__schedule中,而我们查看schedule函数
在这里插入图片描述
可以看到schedule函数中调用了__schedule函数,从而执行进程上下文切换和进程切换的准备部分

七.分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系

从上一节内容我们可以发现部分函数的调用关系:
schedule->__schedule->pick_next_task->context_switch->switch_to->__switch_to

这里的switch_to不是一个函数而是一个宏

asm volatile("pushfl\n\t"      /* 保存当前进程的标志位 */   
         "pushl %%ebp\n\t"        /* 保存当前进程的堆栈基址EBP   */ 
         "movl %%esp,%[prev_sp]\n\t" /* 保存当前栈顶ESP */ 
         "movl %[next_sp],%%esp\n\t" /* 把下一个进程的栈顶放到esp寄存器中,完成了内核堆栈的切换,从此往下压栈都是在next进程的内核堆栈中。 */
         "movl $1f,%[prev_ip]\n\t"    /* 保存当前进程的EIP   */ 
         "pushl %[next_ip]\n\t"   /* 把下一个进程的起点EIP压入堆栈   */
         __switch_canary                   
         "jmp __switch_to\n"  /* 因为是函数所以是jmp,通过寄存器传递参数,寄存器是prev-a,next-d,当函数执行结束ret时因为没有压栈当前eip,所以需要使用之前压栈的eip,就是pop出next_ip。  */

		"1:\t"               /* 认为next进程开始执行。 */         
		"popl %%ebp\n\t"     /* restore EBP   */    
		"popfl\n"         /* restore flags */  
		
		/* output parameters 因为处于中断上下文,在内核中 prev_sp是内核堆栈栈顶 prev_ip是当前进程的eip */ 
		: [prev_sp] "=m" (prev->thread.sp), 
		[prev_ip] "=m" (prev->thread.ip), //[prev_ip]是标号 
		"=a" (last),
		
		"=b" (ebx), "=c" (ecx), "=d" (edx),      
		"=S" (esi), "=D" (edi)

		__switch_canary_oparam

		/* input parameters: next_sp下一个进程的内核堆栈的栈顶 next_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/ 
		: [next_sp] "m" (next->thread.sp), 
		[next_ip] "m" (next->thread.ip),
		
		/* regparm parameters for __switch_to(): */ 
		[prev]     "a" (prev),              
		[next]     "d" (next)               
                                    
		 __switch_canary_iparam
		: /* reloaded segment registers */           
		 "memory");
} while (0)

switch_to实现了进程之间的切换:

  • 先在当前进程prev的内核栈中保存esi,edi及ebp寄存器中的内容。
  • 然后将prev的内核堆栈指针ebp存入prev->thread.esp中。
  • 把下一个运行进程内核栈指针next->thread.esp置入esp寄存器中
  • 将popl指令所在的地址保存在prev->thread.eip中,prev下一次被调度进入此地址
  • 通过jmp指令转入一个函数__switch_to()
  • 恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行

总结

  • 通过系统调用,用户空间的应用程序就会进入内核空间,这就涉及到上下文的切换
  • 用户空间和内核空间具有不同的地址映射以及通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行
  • 所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容
  • 当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

参考引用

https://blog.csdn.net/weixin_43389097/article/details/88743522
https://www.cnblogs.com/fuchen1994/p/5400967.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值