进程的创建和调度
学号后三位:144
文中参考代码出处:https://github.com/mengning/linuxkernel/
本文主要针对进程创建、可执行文件的加载和进程间切换三大部分进行实验并分析。
实验环境:Ubuntu 16虚拟机、VMware 14
1 进程创建
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
1.1 描述进程的数据结构
在操作系统中,进程也需要一个数据结构来保存内核对进程状态等信息,此数据结构我们一般将其称作进程控制块(Process Control Block)。PCB在linux内核中定义为task_struct结构体,并在/include/linux/sched.h源文件中实现。
由于源代码较多,在此处只给出部分常用代码:
volatile long state; //表示进程状态
void *stack; //进程所属堆栈指针
unsigned int rt_priority;//进程优先级
int exit_state;//退出时状态
pid_t pid;//进程号,作为进程的全局标识符
pid_t tgid;//进程组号
struct task_struct __rcu *real_parent;//父进程
struct list_head children;//子进程
struct list_head sibling;//兄弟进程
struct task_struct *group_leader;//所属进程组的主进程
1.2 fork函数对应的内核处理过程do_fork
传统的UNIX中用于复制进程的系统调用是fork。但它并不是Liunx为此实现的唯一的调用,实际上Linux实现了3个:
(1)fork是重量级调用,它建立了父进程的一个完整副本,然后为子进程执行。
(2)vfork类似于fork,但并不创建父进程数据的副本。相反,父子进程之间共享数据。
(3)clone产生线程,可以对父子进程之间的共享、复制进行精确控制。
fork、vfork和close系统调用的入口分别是sys_fork、sys_vfork和sys_clone函数。以上函数从寄存器中取出由用户定义的信息,并调用与体系结构无关的do_fork函数进行进程的复制。do_fork函数的原型如下:
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
所有3个fork机制最终都调用了kernel/fork.c中的do_fork,其代码流程图如下(图片出处为《深入Linux内核架构》人民邮电出版社):
在do_fork中大多数工作是由copy_process函数完成的,其代码流程如下图所示(图片出处为《深入Linux内核架构》人民邮电出版社):
1.3 使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
1.3.1 添加源代码创建rootfs
在此节我们使用qemu和gdb跟踪分析do_fork的调用过程,qemu及gdb环境的搭建,请参考本此处。
我们首先在test.c文件中加入以下代码用于测试:
int Fork(int argc, char *argv[])
{
int child = fork();
if(child < 0){
printf("fail to create a new process\n");
} else {
if(child == 0){
printf("successfully create a new process, and I am the parent\n");
} else {
printf("successfully create a new process, and I am the child\n");
}
}
return 0;
}
并在main函数中加入以下代码完成登记:
1 MenuConfig("fork","Create a new process",Fork);
对menu进行重新编译,并创建rootfs
1 # make rootfs
2 # cd ../rootfs
3 # cp ../menu/init ./
4 # find . | cpio -o -Hnewc |gzip -9 > ../rootfs.img
1.3.2 跟踪分析
打开两个终端,在其中一个中输入以下命令,打开qemu终端:
1 # qemu-system-i386 -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd rootfs.img -s -S -append nokaslr
输入后依旧会弹出一个处于stopped状态的qemu终端:
# gdb
(gdb) file linux-3.18.6/vmlinux
(gdb) target remote:1234
(gdb) b sys_clone
(gdb) b do_fork
(gdb) b dup_task_struct
(gdb) b copy_process
(gdb) b copy_thread
以上命令分别在sys_clone、do_fork、dup_task_struct、copy_process和copy_thread函数调用处加上断点:
根据上述调试方法可以得到如下的结果:
可执行程序工作原理
ELF目标文件格式
ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库。
1.可执行文件(应用程序)可执行文件包含了代码和数据,是可以直接运行的程序。
2.可重定向文件(.o)可重定向文件又称为目标文件,它包含了代码和数据(这些数据是和其他重定位文件和共享的object文件一起连接时使用的)。
.o文件参与程序的连接(创建一个程序)和程序的执行(运行一个程序),它提供了一个方便有效的方法来用并行的视角看待文件的内容,这些.o文件的活动可以反映出不同的需要。
Linux下,我们可以用gcc -c编译源文件时可将其编译成.o格式。
3.共享文件(*.so)也称为动态库文件,它包含了代码和数据(这些数据是在连接时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为ld.so.1,libc.so.1或者 ld-linux.so.1。
图片来自百科
每一部分的具体信息参见 https://baike.baidu.com/item/ELF/7120560?fr=aladdin
在linux下输入“man elf”即可查看其详细的格式定义。
静态链接与动态链接
静态链接
在编译链接时直接将需要的执行代码复制到最终可执行文件中,有点是代码的装在速度块,执 行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对较大。
动态链接
动态链接是在程序运行时由操作系统将需要的动态库加载到内存中。动态链接分为装载时动态链接和运行时动态链接。
**
程序装载
**
编程使用exec*库函数加载一个可执行文件
在之前的fork程序中加入一句execlp("/bin/ls",“ls”,NULL);重新编译
Linux提供了execl、execlp、execle、execv、execvp和execve等6个用以执行一个可执行文件的函数。这些函数的本质都是调用sys_execve()来执行一个可执行文件。使用gdb跟踪do_execve
整体调用关系为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread().
进程调度的时机
进程调度的时机
- 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();
- 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换。context_switch首先调用switch_mm切换CR3,然后调用宏switch_to来进行硬件上的上下文切换。
使用 gdb跟踪schedule函数
next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部
context_switch(rq, prev, next);//进程上下文切换
switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程
31#define switch_to(prev, next, last) \
32do { \
33 /* \
34 * Context-switching clobbers all registers, so we clobber \
35 * them explicitly, via unused output variables. \
36 * (EAX and EBP is not listed because EBP is saved/restored \
37 * explicitly for wchan access and EAX is the return value of \
38 * __switch_to()) \
39 */ \
40 unsigned long ebx, ecx, edx, esi, edi; \
41 \
42 asm volatile("pushfl\n\t" /* save flags */ \
43 "pushl %%ebp\n\t" /* save EBP */ \ 当前进程堆栈基址压栈
44 "movl %%esp,%[prev_sp]\n\t" /* save ESP */ \ 将当前进程栈顶保存prev->thread.sp
45 "movl %[next_sp],%%esp\n\t" /* restore ESP */ \ 讲下一个进程栈顶保存到esp中
46 "movl $1f,%[prev_ip]\n\t" /* save EIP */ \ 保存当前进程的eip
47 "pushl %[next_ip]\n\t" /* restore EIP */ \ 将下一个进程的eip压栈,next进程的栈顶就是他的的起点
48 __switch_canary \
49 "jmp __switch_to\n" /* regparm call */ \
50 "1:\t" \
51 "popl %%ebp\n\t" /* restore EBP */ \
52 "popfl\n" /* restore flags */ \ 开始执行下一个进程的第一条命令
53 \
54 /* output parameters */ \
55 : [prev_sp] "=m" (prev->thread.sp), \
56 [prev_ip] "=m" (prev->thread.ip), \
57 "=a" (last), \
58 \
59 /* clobbered output registers: */ \
60 "=b" (ebx), "=c" (ecx), "=d" (edx), \
61 "=S" (esi), "=D" (edi) \
62 \
63 __switch_canary_oparam \
64 \
65 /* input parameters: */ \
66 : [next_sp] "m" (next->thread.sp), \
67 [next_ip] "m" (next->thread.ip), \
68 \
69 /* regparm parameters for __switch_to(): */ \
70 [prev] "a" (prev), \
71 [next] "d" (next) \
72 \
73 __switch_canary_iparam \
74 \
75 : /* reloaded segment registers */ \
76 "memory"); \
77} while (0)
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行,所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行.
同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。
运行在进程上下文的内核代码是可以被抢占的(Linux2.6支持抢占)。但是一个中断上下文,通常都会始终占有CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制