Linux系统分析---内核调度

本文详细探讨了Linux系统内核中的进程创建,包括进程概念、task_struct结构体、fork/vfork/clone函数以及do_fork函数的工作原理。接着,分析了进程切换的过程,从schedule()函数开始,涉及不同类型的进程调度选择、堆栈切换和地址空间切换等关键步骤。最后,简要介绍了可执行文件加载的流程,从预处理到编译、汇编和链接。通过这些深入分析,有助于理解Linux内核的调度机制。
摘要由CSDN通过智能技术生成

姓名:况逸航

学号:SA18225163

目录

 

一、进程创建

二、进程切换

三、可执行文件加载


一、进程创建

本章节主要介绍在Linux系统内核中最活跃的部分----进程的创建,首先介绍一下进程的概念,进程通说的讲就是一段正在执行的程序,是程序执行的一个实例,他执行上下文、定义执行流。有特殊权限的进程可以管理其他的进程,即分配CPU执行时间。进程是分配系统资源的最小单元,这需要与下面将要提及的线程区别开来,从操作系统角度来看根部不存在线程,所有的资源全部由进程分配调度管理的,只是在多线程应用开发者来看,是在同一个进程中创建多个执行流(线程)。

为了管理进程,内核必须对每个进程进行清晰的描述,内核描述符提供了内核所需了解的进程信息,进程的结构体task_struct,位于内核源码Linux-xxx.xx.x/include/linux/sched.c文件中,主要为任务的状态、堆栈、标志、优先级、父进程等信息,结构体庞大,这里只是作展示(我们使用的为Linux-3.18.6版本内核)。

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;
    ....
}

下面我们介绍进程的创建函数fork()/vfork()/clone(),系统调用的函数为do_frok(),在内核启动时,除了需要手动创建0号进程外,其他的进程均是复制0号进程的部分内容作为其他进程的内容,其中的复制进程的函数即为do_fork(),该函数位于/linux-xxx.xx.x/kernel/fork.c中定义,该函数主要用于进程复制,在三个子进程创建的系统调用sys_clone()、sys_fork()、sys_vfork()中,最后均执行了do_fork函数,唯一的区别是调用时的clone_flags不同。

 

asmlinkage int sys_fork(struct pt_regs regs)//fork创建的子进程是父进程的完整副本,复制了父进程 
                                            //的资源,包括内存的内容task_struct的内容
{
    return do_fork(SIGCHLD, regs.esp, ®s, 0, NULL, NULL);
}

asmlinkage int sys_vfork(struct pt_regs regs)//vfork创建的子进程与父进程共享数据段,且由 
                                             //vdork()创建的子进程将现于父进程运行
{
    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, ®s, 0, NULL, NULL);
}

asmlinkage int sys_clone(struct pt_regs regs)//Linux上创建线程一般使用的是pthread库 实际上 
                                          //linux也给我们提供了创建线程的系统调用,就是clone
{
    unsigned long clone_flags;
    unsigned long newsp;
    int __user *parent_tidptr, *child_tidptr;

    clone_flags = regs.ebx;
    newsp = regs.ecx;
    parent_tidptr = (int __user *)regs.edx;
    child_tidptr = (int __user *)regs.edi;
    if (!newsp)
        newsp = regs.esp;
    return do_fork(clone_flags, newsp, ®s, 0, parent_tidptr, child_tidptr);
}
long do_fork(unsigned long clone_flags,//与clone()参数flags相同
          unsigned long stack_start,   //与clone()参数stack_start相同
          struct pt_regs *regs,        //指向内核态堆栈通用寄存器值的指针,通用寄存器的值是在从        
                                       //用户态切换到内核态堆栈中的
          unsigned long stack_size,    //未使用,均置0,用户状态下的堆栈的大小
          int __user *parent_tidptr,   //与clone的pid参数相同,父进程在用户态下的pid的地址
          int __user *child_tidptr)    //与clone的ctid参数相同,子进程在用户动态下pid的地址

参数描述
clone_flags用来控制进程复制过的一些属性信息, 描述你需要从父进程继承那些资源。该标志位的4个字节分为两部分。最低的一个字节为子进程结束时发送给父进程的信号代码,通常为SIGCHLD;剩余的三个字节则是各种clone标志的组合(本文所涉及的标志含义详见下表),也就是若干个标志之间的或运算。通过clone标志可以有选择的对父进程的资源进行复制。
stack_start子进程用户态堆栈的地址
regs是一个指向了寄存器集合的指针, 其中以原始形式, 保存了调用的参数, 该参数使用的数据类型是特定体系结构的struct pt_regs,其中按照系统调用执行时寄存器在内核栈上的存储顺序, 保存了所有的寄存器, 即指向内核态堆栈通用寄存器值的指针,通用寄存器的值是在从用户态切换到内核态时被保存到内核态堆栈中的(指向pt_regs结构体的指针。当系统发生系统调用,即用户进程从用户态切换到内核态时,该结构体保存通用寄存器中的值,并被存放于内核态的堆栈中)
stack_size用户状态下栈的大小, 该参数通常是不必要的, 总被设置为0
parent_tidptr父进程在用户态下pid的地址,该参数在CLONE_PARENT_SETTID标志被设定时有意义
child_tidptr子进程在用户太下pid的地址,该参数在CLONE_CHILD_SETTID标志被设定时有意义

首先确定是否需要向ptracer报告,当调用kernel_thread或者明确请求CLONE_UNTRACED时,不做报告;否则,报告是否启用分叉类型事件。处理完上述时间之后开始复制进程copy_process()。

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;
	}
p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace);

随后将新进层加载到进程链表中,把新进程加到pidhash散列表中,并增加任务计数值,通过拷贝父进程的上下文来初始化硬件的上下文,最后将新进程挂到就绪队列中,并重新启动调度程序使其运行,向父进程返回子进程的 PID,设置子进程从 do_fork() 返回 0 值。在整个过程中对task_struct p仅仅是将拷贝的进程复制给他,获取其pid与数据结构,并将其唤醒。

if (!IS_ERR(p)) {                             //判断返回值是否正确
		struct completion vfork;
		struct pid *pid;

		trace_sched_process_fork(current, p);

		pid = get_task_pid(p, PIDTYPE_PID);
		nr = pid_vnr(pid);

		if (clone_flags & CLONE_PARENT_SETTID)//判断clone_fork中是否有CLONE_PARENT_SETTID 
                                              //标志
			put_user(nr, parent_tidptr);

		if (clone_flags & CLONE_VFORK) {      //判断clone_fork中是否有CLONE_VFORK标志
			p->vfork_done = &vfork;
			init_completion(&vfork);          //这个函数的作用是在进程创建的最后阶段,父进程 
                                              //会将自己设置为不可中断状态,然后睡眠在等待队 
                                              //列上(init_waitqueue_head()函数 就是将父进程 
                                              //加入到子进程的等待队列),等待子进程的唤醒。
			get_task_struct(p);
		}

		wake_up_new_task(p);

		/* forking complete and child started to run, tell 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);
	} else {
		nr = PTR_ERR(p);
	}
	return nr;
}

我们通过编写一个简单的子进程创建的程序,采用strace跟踪程序来对fork/vfork函数进行简单的跟踪。执行编译指令及跟踪指令,可以显示当前的系统调用的函数及调用的次数,我们可以依次看到系统调用函数调用的顺序

gcc text.c -o text 

strace -c ./text

//text.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(){
        pid_t pid;
        pid = vfork();//pid = fork();
        if(pid == -1){
          printf("Failed to fork");
          return -1;
        }
        if(pid > 0){
          printf("parent pid :%d\n",getpid());
          sleep(20);
        }
        else if(pid == 0) {
         printf("child process pid:%d\n",getpid());
        }
 printf("fork after ......\n");
 return 0;
}

执行gdb进行程序跟踪,我们在stat_kernel和do_fork两个函数处设定断点,我们发现从start_kernel开始程序就开始调用do_fork函数进行进程创建,在创建完第二个进程之后关闭进程抢占,随后又进入do_fork函数,继续创建进程。在创建完第三个进程之后开始进行调度。

执行下面的指令开启gdb,我们依旧使用qemu虚拟机作为试验环境,为了简单期间我们采用linux-3.18.6内核进行实验。

qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd ../rootfs.img -s -S
//qemu-system-x86_64 linux-5.0/arch/x86/boot/bzImage -initrd ../rootfs.img -s -S

另开一新的终端,执行gdb执行,设定断点:

gdb
file linux-3.18.6/vmlinux
target remote:1234
b do_fork

 

二、进程切换

Linux系统进程调度和切换的主要内容为:

(1):从schedule()开始,几种不同类型的进程之间的调度选择;在相同类型的进程之间的调度选择算法

(2):从CPU的IP值的变化上,说明在switch_to宏执行后,执行分析

(3):堆栈发生切换位置,在切换堆栈前后,current_thread_info变化

(4):地址空间发生切换,解释地址空间的切换不会影响后续切换代码的执行

(5):current宏所代表的进程发生变化的源码位置

(6):任务状态段中关于内核堆栈的信息发生变化源码位置

跟踪schedule函数执行情况:(https://www.cnblogs.com/Daniel-G/p/3307298.html

__schedule()函数位于linux-xxx.xx.x/kernel/sched/core.c文件下,下面我们进行详细的调度切换分析。

1,从schedule()开始,说明几种不同类型的进程之间的调度选择;在相同类型的进程之间的调度选择算法。

在schedule()函数中,

 

首先禁止抢占,获取当前CPU,该CPU的执行队列,队列上正在执行的进程,以及该进程的交换计数信息并释放该进程占用的锁。之后,对禁止中断,更新运行队列时钟,该队列的自旋时钟加锁,后清除当前进程的thread_flag中TIF_NEED_RESCHED,

 

如果进程不在可运行状态,并且可被抢占,若进程处于非阻塞挂起,则将其改为可运行,否则调用deactivate_task()函数,并修改上下文交换次数。其中在deactive_task()函数中调用了denqueue_task()函数:

 

P进程调用属于自己调度类的dequeue_task()方法,将p从当前rp运行队列上移出。例如对于公平调度队列中的进程调用以下函数:

对p的所有实体除含有子实体的父进程外,从公平队列中移除。

如果运行队列上进程数是0,则先通过idle_balance函数从其他CPU上调度,进行负载均衡。

对当前运行的进程prev,通过调用它所属的类的put_prev_task方法,将当前进程放入运行队列的合适位置。下图展示过程,图为公平调度类的调度方法,之后对实时调度方法的说明(idle类方法为空):

与方法put_prev_entity()方法,将当前进程加入公平调度队列。因为如果该类是公平调度类,则调度一定会在公平调度队列中有一位置,更新当前实例的状态,并入队:

其中入队位置有该值决定entity_key:

对于采用实时调度的类,调用update_curr_rt函数,并置当前进程执行开始时间是0

下图为update_curr_tr函数,

计算delta_exec值为运行队列现在的时钟值与当前进程开始值。更改当前进程的状态,修改当前实时进程的总运行时间与开始时间,对实时调度队列中的实例更新时间。

之后,在运行队列上选择下一个进程中pick_next_task函数。

对于运行队列,如果队列中进程数与公平调度队列中的进程数相同,即没有实时进程时则在公平调度队列中选择进程:

对调度类中具有最高优先级的类赋值给class,调用该类的pick_next_task方法,根据不同调度类又分为:

对于实时进程则:

对于一个实例,如果它不在实时队列组中,则返回拥有这个实例的task_struct结构为next进程并修改执行开始时间为运行队列当时钟前值。

对于公平调度进程则:

返回公平调度队列上选择不在公平调度组中的task_struct。

对于idle,返回队列中的idle task_struct结构,在调度过程中,永远不会返回NULL,因为至少有idle进程的存在。

在队列中从不同类中,选择出了将要被调度的类后,如果选择的进程next与prev不同则,进行进程的上下文切换:

修改交换次数,将next至为当前进程,进行切换。

2,执行完switch_to后,又执行了battier函数,之后又执行finish_task_switch函数

 

另:

struct task_struct *__switch_to(struct task_struct *prev,struct task_struct *next);

将next->thread.esp中的数据存入esp寄存器中

在switch_to宏执行后,执行ret_from_fork()函数。

执行完这个函数之后,执行include/asm-x86/system.h 下的__switch_to函数()

再执行__unlazy_fpu()函数。

3,堆栈发生切换位置,在切换堆栈前后,current_thread_info变化

对于切换堆栈,在switch_to中查找修改堆栈指针代码即可即:

图中movel %[next_sp],%%esp 即为修改堆栈指针,指向next进程的堆栈。因为在内核态中,栈顶指针减去8K偏移(两页)便可得到thread_info位置,从而,在切换后current_thread_info内容为切换后的新进程的thread_info内容。

4,地址空间发生切换,解释地址空间的切换不会影响后续切换代码的执行

切换地址空间在context_switch函数的switch_mm方法,在switch_mm中,重新加载页表即修改cr3寄存器的值:

切换地址空间发生在切换堆栈之前,不会影响后续代码执行,因为进程的切换发生在内核态,内核态地址空间是共用的。没有修改堆栈指针及其他寄存器的值,即堆栈没有变,栈内值未发生改变。

5,current宏所代表的进程发生变化的源码位置

修改该CPU的current_task为next_p,即current宏发生了改变。

6,任务状态段中关于内核堆栈的信息发生变化源码位置

Tss段在_switch_to中被声明,并被赋值:

其中,esp0即为内核堆栈栈底指针

三、可执行文件加载

本小节主要介绍程序的编译链接过程,为了解释方便我们从最简单的helloworld.c开始讲起,当我们开始编译连接到执行的整个过程其实夹杂了很多处理,下图详细的展示了真正执行的操作,我们执行的指令gcc helloworld.c -o hello 执行的是从预处理->编译->汇编->链接最终生成可执行文件hello。

预处理过程主要做一些代码文本的替换工作,这是一个递归逐层展开的过程:

(1)将所有的#define删除,并展开所有的宏定义

(2)处理所有的条件预编译指令,如:#if  #ifdef #elif #else #endif

(3)处理#include预编译指令,将被包含的文件插进到该指令的位置,这个过程是递归的

(4)删除所有的注释//与/* */

(5)添加行号与文件名标识,以便产生调试用的行号信息以及编译错误或警告时能够显示行号

(6)保留所有的#pragma编译器指令,因为编译器需要使用它们

预编译指令:

cpp hello.cpp > hello.i
gcc -E hello.cpp -o hello.i
g++ -E hello.cpp -o hello.i

编译过程为将预处理完的文件进行一系列词法分析(lex)、语法分析(yace)、语义分析及优化后生成汇编代码,整个过程是程序构建的核心部分。

编译指令:

/usr/lib/gcc/i586-suse-linux/4.1.2/cc1plus Hello.cpp
gcc -S Hello.cpp -o Hello.s
g++ -S Hello.cpp -o Hello.s

对于含c++的特性cpp文件,应使用cc1plus进行编译,或使用gcc命令来编译(会通过后缀名来选择调用cc1还是cc1plus)。

汇编过程是将汇编代码生成机器指令的过程。具体操作指令:

as Hello.s -o Hello.o
gcc -c Hello.cpp -o Hello.o
g++ -c Hello.cpp -o Hello.o

至此,产生的目标文件在结构上已经很像最终的可执行文件。

链接过程:这列将的链接,严格的说应该属于静态链接,将多个目标文件、库链接在一起最终生成真正可运行的可执行文件。这里可以介绍一下静态和动态链接产生的原因。

 在我们的实际开发中,不可能将所有代码放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个*.c文件会形成一个*.o文件,为了满足前面说的依赖关系,则需要将这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序。这个链接的过程就是静态链接。

动态链接出现的原因就是为了解决静态链接中提到的两个问题,一方面是空间浪费,另外一方面是更新困难。下面介绍一下如何解决这两个问题。 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。(具体细节可参考该链接https://blog.csdn.net/kang___xi/article/details/80210717

下面我们主要跟踪一下执行execve的系统调用函数do_execve,exec函数主要工作是根据指定的文件名找到可执行的文件,并用他取代调用进程的内容。换句话说,就是在调用进程内部执行一个可执行文件这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。

  与一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID等一些表面上的信息仍保持原样,颇有些神似"三十六计"中的"金蝉脱壳"。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。

参考上文中提到的开启gdb跟踪器,在do_execve函数上设定断点。发现使劲执行操作的为do_execve_common函数,在该函数上设定断点,直接进程该函数,具体内容可以直接进入该函数内查看,我们只做提及。

现在我们给出采用exec*库中提供的函数创建简单的程序,先介绍一下exec家族中的函数:

    (1)int execl(const char *path, const char *arg, ......);

  (2)int execle(const char *path, const char *arg, ...... , char * const envp[]);

  (3)int execv(const char *path, char *const argv[]);

  (4)int execve(const char *filename, char *const argv[], char *const envp[]);

  (5)int execvp(const char *file, char * const argv[]);

  (6)int execlp(const char *file, const char *arg, ......);

他们之间的区别是:

       1)前四个取路径名做为参数,后两个取文件名做为参数,如果文件名中不包含 “/” 则从PATH环境变量中搜寻可执行文件, 如果找到了一个可执行文件,但是该文件不是连接编辑程序产生的可执行代码文件,则当做shell脚本处理。

  2)前两个和最后一个函数中都包括“ l ”这个字母 ,而另三个都包括“ v ”, " l "代表 list即表 ,而" v "代表 vector即矢量,也是是前三个函数的参数都是以list的形式给出的,但最后要加一个空指针,如果用常数0来表示空指针,则必须将它强行转换成字符指针,否则有可能出错。,而后三个都是以矢量的形式给出,即数组。

  3)与向新程序传递环境变量有关,如第二个和第四个以e结尾的函数,可以向函数传递一个指向环境字符串指针数组的指针。即自个定义各个环境变量,而其它四个则使用进程中的环境变量。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

int main(int argc, char *argv[])
{
  //以NULL结尾的字符串数组的指针,适合包含v的exec函数参数
  char *arg[] = {"ls", "-a", NULL};
  
  /**
   * 创建子进程并调用函数execl
   * execl 中希望接收以逗号分隔的参数列表,并以NULL指针为结束标志
   */
  if( fork() == 0 )
  {
    // in clild 
    printf( "1------------execl------------\n" );
    if( execl( "/bin/ls", "ls","-a", NULL ) == -1 )
    {
      perror( "execl error " );
      exit(1);
    }
  }
if( fork() == 0 )//创建子进程,并调用execv函数,execv希望接收到一个以NULL结尾的字符串数组的指针
  {
    // in child 
    printf("2------------execv------------\n");
    if( execv( "/bin/ls",arg) < 0)
    {
      perror("execv error ");
      exit(1);
    }
  }
return 0;
}
//执行结果
1------------execl------------
.  ..  .deps  exec  exec.o  .libs  Makefile
2------------execv------------
.  ..  .deps  exec  exec.o  .libs  Makefile

总结

通过跟踪相关的系统调用函数,可以更为清晰的了解程序执行过程中调用了那些程序,调用了多少次。在本节内容中重点介绍了系统调度和切换的函数__schedule(),通过阅读改程序代码,让自己更加清楚的了解Linux内核的调度机制及切换时机,为以后对Linux内核的基础知识的学习打好了基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值