linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

427+原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/

进程

进程是处于执行期的程序以及它所管理的资源(如打开的文件、挂起的信号、进程状态、地址空间等等)的总称。进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,是系统进行资源分配和调度运行的基本单位。
进程由三部分组成:
a. 程序
b. 数据
c. 进程控制块(PCB):为了管理和控制进程,系统在创建每个进程时,都为其开辟一个专用的存储区,用以记录它在系统中的动态特性。系统根据存储区的信息对进程实施控制管理。进程任务完成后,系统收回该存储区,进程随之消亡,这一存储区就是进程控制块

PCB随着进程的创建而建立,撤销而消亡。系统根据PCB感知一个进程的存在,PCB是进程存在的唯一物理标识(这一点可以类比作业控制块JCB)

进程描述符 task_struct

Linux内核的进程控制块是task_struct结构体。Linux内核通过进程描述符task_struct结构体来管理进程,这个结构体包含了一个进程所需的所有信息。

task_struct 主要包含了以下内容:

  1)标示符 : 描述本进程的唯一标示符,用来区别其他进程。
1330	pid_t pid;
1331	pid_t tgid;

在Linux系统中,一个线程组中的所有线程使用和该线程组的领头线程(该组中的第一个轻量级进程)相同的PID,并被存放在tgid成员中。只有线程组的领头线程的pid成员才会被设置为与tgid相同的值。注意,getpid()系统调用返回的是当前进程的tgid值而不是pid值。

  2)状态 :任务状态,退出代码,退出信号等。
1236	volatile long state;	/* -1 unrunnable, 0 runnable, >0 stopped */

state成员的可能取值如下:

203#define TASK_RUNNING		0
204#define TASK_INTERRUPTIBLE	1
205#define TASK_UNINTERRUPTIBLE	2
206#define __TASK_STOPPED		4
207#define __TASK_TRACED		8
208/* in tsk->exit_state */
209#define EXIT_DEAD		16
210#define EXIT_ZOMBIE		32
211#define EXIT_TRACE		(EXIT_ZOMBIE | EXIT_DEAD)
212/* in tsk->state again */
213#define TASK_DEAD		64
214#define TASK_WAKEKILL		128
215#define TASK_WAKING		256
216#define TASK_PARKED		512
217#define TASK_STATE_MAX		1024

TASK_RUNNING表示进程要么正在执行,要么正要准备执行。
TASK_INTERRUPTIBLE表示进程被阻塞(睡眠),直到某个条件变为真。条件一旦达成,进程的状态就被设置为TASK_RUNNING。
TASK_UNINTERRUPTIBLE的意义与TASK_INTERRUPTIBLE类似,除了不能通过接受一个信号来唤醒以外。
__TASK_STOPPED表示进程被停止执行。
__TASK_TRACED表示进程被debugger等进程监视。
EXIT_ZOMBIE表示进程的执行被终止,但是其父进程还没有使用wait()等系统调用来获知它的终止信息。
EXIT_DEAD表示进程的最终状态。
EXIT_ZOMBIE和EXIT_DEAD也可以存放在exit_state成员中。

3)进程堆栈
1237	void *stack;
4)进程调度
1253	int prio, static_prio, normal_prio;
1254	unsigned int rt_priority;
1255	const struct sched_class *sched_class;
1256	struct sched_entity se;
1257	struct sched_rt_entity rt;
...
1272	unsigned int policy;
1273	int nr_cpus_allowed;
1274	cpumask_t cpus_allowed;

static_prio用于保存静态优先级。
rt_priority用于保存实时优先级。
normal_prio的值取决于静态优先级和调度策略。
prio用于保存动态优先级。
policy表示进程的调度策略。

do_fork过程

do_fork代码:

1617/*
1618 *  Ok, this is the main fork-routine.
1619 *
1620 * It copies the process, and if successful kick-starts
1621 * it and waits for it to finish using the VM if required.
1622 */
1623long do_fork(unsigned long clone_flags,
1624	      unsigned long stack_start,
1625	      unsigned long stack_size,
1626	      int __user *parent_tidptr,
1627	      int __user *child_tidptr)
1628{
1629	struct task_struct *p;   //进程结构
1630	int trace = 0;
1631	long nr;
1632
1633	/*
1634	 * Determine whether and which event to report to ptracer.  When
1635	 * called from kernel_thread or CLONE_UNTRACED is explicitly
1636	 * requested, no event is reported; otherwise, report if the event
1637	 * for the type of forking is enabled.
1638	 */
1639	if (!(clone_flags & CLONE_UNTRACED)) {
1640		if (clone_flags & CLONE_VFORK)
1641			trace = PTRACE_EVENT_VFORK;
1642		else if ((clone_flags & CSIGNAL) != SIGCHLD)
1643			trace = PTRACE_EVENT_CLONE;
1644		else
1645			trace = PTRACE_EVENT_FORK;
1646
1647		if (likely(!ptrace_event_enabled(current, trace)))
1648			trace = 0;
1649	}
1650
1651	p = copy_process(clone_flags, stack_start, stack_size,
1652			 child_tidptr, NULL, trace);
1653	/*
1654	 * Do this prior waking up the new thread - the thread pointer
1655	 * might get invalid after that point, if the thread exits quickly.
1656	 */
1657	if (!IS_ERR(p)) {
1658		struct completion vfork;
1659		struct pid *pid;
1660
1661		trace_sched_process_fork(current, p);
1662
1663		pid = get_task_pid(p, PIDTYPE_PID);
1664		nr = pid_vnr(pid);
1665
1666		if (clone_flags & CLONE_PARENT_SETTID)
1667			put_user(nr, parent_tidptr);
1668
1669		if (clone_flags & CLONE_VFORK) {
1670			p->vfork_done = &vfork;
1671			init_completion(&vfork);
1672			get_task_struct(p);
1673		}
1674
1675		wake_up_new_task(p);
1676
1677		/* forking complete and child started to run, tell ptracer */
1678		if (unlikely(trace))
1679			ptrace_event_pid(trace, pid);
1680
1681		if (clone_flags & CLONE_VFORK) {
1682			if (!wait_for_vfork_done(p, &vfork))
1683				ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
1684		}
1685
1686		put_pid(pid);
1687	} else {
1688		nr = PTR_ERR(p);
1689	}
1690	return nr;
1691}

调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
初始化vfork的完成处理信息(如果是vfork调用)
调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
如果是vfork调用,需要阻塞父进程,知道子进程执行exec。

gdb跟踪do_fork过程

实验环境:实验楼 https://www.shiyanlou.com/courses/195
1.qemu启动根文件系统(https://github.com/mengning/menu.git)

cd LinuxKernel   
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
make rootfs

linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

2.gdb调试
调试准备:

cd LinuxKernel
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -S -s

打开一个新的终端:

 gdb
 file linux-3.18.6/vmlinux
 target remote:1234

设置断点:

 b do_fork
 b dup_task_struct
 b copy_process
 b copy_thread
 b ret_from_fork

linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
1.do_fork
首先调用copy_process()为子进程复制出一份进程信息
2.copy_process
首先调用dup_task_struct()复制当前的task_struct。
copy_thread()初始化子进程内核栈,为新进程分配并设置新的pid
3.copy_thread
对子进程的thread.sp赋值。
将父进程的寄存器信息复制给子进程。
4.新进程从ret_from_fork处开始执行

编译链接的过程和ELF可执行文件格式

编译链接过程

linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

ELF可执行文件

linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
ELF文件(目标文件)格式主要三种:

可重定向文件:文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。(目标文件或者静态库文件,即linux通常后缀为.a和.o的文件)
可执行文件:文件保存着一个用来执行的程序。(例如bash,gcc等)
共享目标文件:共享库。文件保存着代码和合适的数据,用来被下连接编辑器和动态链接器链接。(linux下后缀为.so的文件。)
目标文件既要参与程序链接又要参与程序执行:
一般的 ELF 文件包括三个索引表:ELF header,Program header table,Section header table。

ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
Program header table:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
Section header table:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。

exec*库函数加载一个可执行文件

编写test.c
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

预编译生成test.cpp

gcc -E -o test.cpp test.c -m32

test.cpp:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

编译生成汇编test.s

gcc -x cpp-output -S -o test.s test.cpp -m32

test.s(删除了部分内容):
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

编译成目标文件test.o

gcc -o test test.s -m32

linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

静态编译:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
比较两个文件:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

静态链接浪费空间,因为每个可执行程序中对所有需要的目标文件都要有一份副本 。但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

gdb跟踪分析

设置断点:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
调试:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

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 }; //调用do_execve_common 
							return do_execve_common(filename, argv, envp); 
							}

新的可执行程序通过修改内核堆栈eip作为新程序的起点,
从new_ip开始执行后start_thread把返回到用户态的位置从int 0x80的下一条指令变成新加载的可执行文件的入口位置。
当execve系统调用返回时,返回新的可执行程序的执行起点(main函数),所以execve系统调用返回后新的可执行程序能顺利执行。

execve系统调用返回时,如果是静态链接,elf_entry指向可执行文件规定的头部(main函数对应的位置0x8048***);如果需要依赖动态链接库,elf_entry指向动态链接器的起点。动态链接主要是由动态链接器ld来完成的。

gdb分析shcedule()函数

实验环境linux-3.18.6 menu
首先设几个断点分别是schedule,pick_next_task,context_switch,__switch_to
调试:
linux进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

分析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),                 
                                    
		/* clobbered output registers: */     
		 "=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)

总结

1、Linux系统的一般执行过程

正在运行的用户态进程X切换到运行用户态进程Y的过程

(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

2.几种特殊情况

(1)通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换;

(2)内核线程主动调用schedule(),只有进程上下文的切换,没有发生中断上下文的切换,与最一般的情况略简略;

(3)创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork;

(4)加载一个新的可执行程序后返回到用户态的情况,如execve;