linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换

学号239
原创作品,转载请注明出处。
本实验资源来源: https://github.com/mengning/linuxkernel/
实验环境:
1,VMware Workstation Pro
2,Ubuntu 18.04 虚拟机

一、阅读理解task_struct数据结构

代码来源:http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235
该结构部分代码:

struct task_struct {
	volatile long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
	void *stack;
	atomic_t usage;
	unsigned int flags;	/* per process flags, defined below */
	unsigned int ptrace;

#ifdef CONFIG_SMP
	struct llist_node wake_entry;
	int on_cpu;
	struct task_struct *last_wakee;
	unsigned long wakee_flips;
	unsigned long wakee_flip_decay_ts;

	int wake_cpu;
#endif
	int on_rq;

	int prio, static_prio, normal_prio;
	unsigned int rt_priority;
	const struct sched_class *sched_class;
	struct sched_entity se;
	struct sched_rt_entity rt;
#ifdef CONFIG_CGROUP_SCHED
	struct task_group *sched_task_group;
#endif
	struct sched_dl_entity dl;

#ifdef CONFIG_PREEMPT_NOTIFIERS
	/* list of struct preempt_notifier: */
	struct hlist_head preempt_notifiers;
#endif

#ifdef CONFIG_BLK_DEV_IO_TRACE
	unsigned int btrace_seq;
#endif

	unsigned int policy;
	int nr_cpus_allowed;
	cpumask_t cpus_allowed;
	...
}

在阅读这个结构体之前,我们必须了解进程与程序的区别,进程是程序的一个执行的实例,为了管理进程,操作系统必须对每个进程所做的事情进行清楚的描述,为此,操作系统使用数据结构来代表处理不同的实体,这个数据结构就是通常所说的进程描述符或进程控制块(PCB),在linux操作系统下这就是task_struct结构 ,它包含了这个进程的所有信息,在任何时候操作系统都能够跟踪这个结构的信息.
其中大概包含了以下内容:

PCB
进程的标识符
进程状态
调度优先级
内存指针
上下文数据
……

进程的执行需要配合PCB,比如通过PCB的 mm可以查看进程在内存中的空间,里面有可能存的是数据段,也有可能是代码段;可以通过 files里的句柄,对被进程打开的文件进行读写。

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

fork、vfork、clone三个系统调用都可以创建一个新进程,而且都是通过调用do_fork来实现的
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;
}

do_fork处理了以下内容:

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

三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork

使用内核5.0启动Menu OS

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

打开gdb开始调试并打上以下断点:
1.sys_clone
2.do_fork
3.dup_task_struct
4.copy_process
5.copy_thread
6.ret_from_fork
7.alloc_thread_info_node
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
开始调试
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
运行结果:
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
在do_fork函数中,以ret_from_fork函数为执行起点,复制父进程的内存堆栈和数据,并修改某些参数实现子进程的定义和初始化,创建子进程的工作完成后,通过sys_call exit函数退出并pop父进程的内存堆栈,实现新进程的创建工作。

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

从源文件Hello.c编译链接成Hello.out,需要经历如下步骤:
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
ELF可执行文件格式:
ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库:
1.一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
2.一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件。
3.一个共享库文件保存着代码和合适的数据,用来被不同的两个链接器链接。

五、程序装载

编程使用exec*库函数加载一个可执行文件
在之前的fork程序中加入

execlp("/bin/ls",“ls”,NULL);

接着使用gdb跟踪do_execve:
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
可以看出,调用顺序为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread()。

六、进程调度

使用gdb跟踪一个schedule()函数:
linux操作系统分析(三):进程创建、可执行文件的加载和进程执行进程切换
可以看出 schedule调用_schedule,_schedule调用pick_next_task,context_switch函数,context_switch函数调用__switch_to

switch_to中的汇编代码:

do {
 
    unsigned long ebx, ecx, edx, esi, edi;
 
    asm volatile("pushfl\n\t"       /* save    flags */
             "pushl %%ebp\n\t"      /* save    EBP   */
             "movl %%esp,%[prev_sp]\n\t"    /* save    ESP   */
             "movl %[next_sp],%%esp\n\t"    /* restore ESP   */
             "movl $1f,%[prev_ip]\n\t"  /* save    EIP   */
             "pushl %[next_ip]\n\t" /* restore EIP   */
             __switch_canary
             "jmp __switch_to\n"    /* regparm call  */
             "1:\t"
             "popl %%ebp\n\t"       /* restore EBP   */
             "popfl\n"          /* restore flags */
 
             /* output parameters */
             : [prev_sp] "=m" (prev->thread.sp),
               [prev_ip] "=m" (prev->thread.ip),
               "=a" (last),
 
               /* clobbered output registers: */
               "=b" (ebx), "=c" (ecx), "=d" (edx),
               "=S" (esi), "=D" (edi)
 
               __switch_canary_oparam
 
               /* input parameters: */
             : [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.在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。
2.将prev的内核堆栈指针ebp存入prev->thread.esp中。
3.把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中
4.将popl指令所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度
5.通过jmp指令转入一个函数__switch_to()
6.恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行。

六、总结

(1)Linux通过复制父进程来创建一个新进程,通过调用do_fork来实现并为每个新创建的进程动态地分配一个task_struct结构。fork()函数被调用一次,但返回两次。可以通过fork,复制一个已有的进程,进而产生一个子进程。
(2)Linux的进程调度基于分时技术和进程的优先级,内核通过调用schedule()函数来实现进程调度,其中context_switch宏用于完成进程上下文切换,它通过调用switch_to宏来实现关键上下文切换。
(3)进程上下文切换需要保存切换进程的相关信息(thread.sp和thread.ip);中断上下文的切换是在一个进程的用户态到一个进程的内核态,或从进程的内核态到用户态,切换进程需要在不同的进程间切换,但一般进程上下文切换是套在中断上下文切换中的。