学号085
原创作品转载请注明出处+本实验来源 https://github.com/mengning/linuxkernel/
实验初探
计算机是一种精密的仪器,有一点错误都是不能成功执行的,计算机软件是靠一个个程序组成的,而程序又是一系列指令所组成。通过执行这样的一条条指令,计算机就能完成一个个任务了。 这里有两个概念比较容易混淆,平时我们在学习计算机的时候也是这样,那就是进程和程序的区别,这也是这篇博客的主要内容,进程。简单点讲,进程就是程序的动态执行,是一个运行起来的程序。Linux其中一个重要的功能就是管理各种状态的进程,包括创建进程、管理进程、调度进程等一系列操作,正是由于有这些操作,才发挥了系统强大的能力。首先我们一起来熟悉一下进程。一般来说,进程都要经历3个状态,分别是就绪态、阻塞态、运行态。但是Linux操作系统将进程的状态进行了进一步划分,分为运行态、不可中断阻塞状态、可中断阻塞状态、挂起状态、僵尸状态5个状态。它们的具体含义是:
运行状态:当进程正在被CPU执行,或处于就绪状态,则称进程处于运行状态。
可中断睡眠状态:进程处于阻塞状态,系统不会调度其运行。
暂停状态:进程收到有关信号就会进入暂停状态,可用SIGCONT进行唤醒
僵死状态:进程停止运行
不可中断睡眠状态:使用wake_up()函数明确唤醒时才能转换到可运行的就绪状态。
有了这些基础知识,然后我们就可以开始进行创建新进程的分析了。
进程的创建
首先,我们接触到一个系统调用函数fork,调用这个函数的时候,系统将会从用户态到内核态的切换。那么在程序中调用系统调用fork需要经过哪些步骤呢?目前主要有这么几个步骤:
-
调用fork这个系统调用,首先是软中断,堆栈切换到内核堆栈,保存相应的寄存器,进行执行地址的跳转。
-
查询相关的中断向量表,找到中断向量表中的函数名称,这里fork对应的函数名称是sys_clone函数。
-
执行sys_clone,这个函数不是直接执行,而是都会执行一个do_fork函数,产生一个新的的进程。
下面是do_fork()的解释
do_fork()
{
copy_process(....){ //复制进程相关信息
dup_tusk_struct(current){ //复制PCB
alloc_task_struct_node() //相关PCB指针赋值
arch_dup_task_struct()
alloc_thread_info_node() //创建8KB大小的内存空间放置stack以及thread_info,返回stack起始地址ti
setup_thread_info() //初始化thread_info
}
retval=copy_thread(P) //设置新进程的起始地址,新进程的内核堆栈
}
}
下面是copy_thread()的代码解释
copy_thread()
p->thread.ip=ret_from_fork //入口地址
p->thread.sp=childregs //将新进程的内核堆栈指向
childregs=current_pt_regs //拷贝父进程内核堆栈数据
childregs->sp=sp; //将拷贝的sp设置为传入的sp
此时子进程的内核堆栈如下:
stack(ti),childregs : current_pt_regs 复制
ax=0; 修改ax
sp=sp; 修改sp
最后再进行一下跳转,并将所有子进程压入栈中的数据进行弹出,这样子进程的所有数据都有了,就可以自己运行了。
说了这么多理论,下面来开始我们的实验,进行一下验证。
int ForkProcess()
{
int pid;
/* fork another process */
pid = fork();
if (pid<0)
{
/* error occurred */
printf("Fork Failed!");
//exit(-1);
}
else if (pid==0)
{
/* child process */
//execlp("/bin/ls","ls",NULL);
printf("This is child Process!,my PID is %d\n",pid);
}
else
{
/* parent process */
/* parent will wait for the child to complete*/
printf("THis is parent process!,my process is %d\n",pid);
//wait(NULL);
printf("Child Complete!");
//exit(0);
}
return 0;
}
这段代码调用fork()创建一个子进程,以下是这个函数在qemu中的运行结果:
fork()函数调用一次会返回两次,在父进程中返回的是创建的子进程的pid,在子进程中返回的是0,因此可以通过返回的pid的值来判断输出的信息是来自于子进程还是父进程,接着我们使用gdb开始跟踪fork()的执行过程。通过粗略的阅读源码,我们在_do_fork,copy_process,dup_task_struct,copy_thread这几个函数打断点。
(由于断点是提前打的,事实上内核在运行时就有大量的fork,所以我斌没有执行到之前集成的程,直接打断点就可以跟踪到这些代码了)
经过跟踪发现_do_fork()主要通过调用copy_process()复制父进程信息获取pid,pid是copy_process的返回值,并调用wake_up_new_task将子进程加入调度队列。故接下来要进入copy_process分析。
copy_process的代码可以看出它主要调用dup_task_struct复制父进程的进程描述符、信息检查、初始化、把进程状态设置为TASK_RUNNING等。
可执行程序工作原理
ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库。
1.可执行文件(应用程序)可执行文件包含了代码和数据,是可以直接运行的程序。
2.可重定向文件(.o)可重定向文件又称为目标文件,它包含了代码和数据(这些数据是和其他重定位文件和共享的object文件一起连接时使用的)。
.o文件参与程序的连接(创建一个程序)和程序的执行(运行一个程序),它提供了一个方便有效的方法来用并行的视角看待文件的内容,这些.o文件的活动可以反映出不同的需要。
Linux下,我们可以用gcc -c编译源文件时可将其编译成.o格式。
3.共享文件(*.so)也称为动态库文件,它包含了代码和数据(这些数据是在连接时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为ld.so.1,libc.so.1或者 ld-linux.so.1。
在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().
进程调度
1、中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();
2、内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
3、用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换。context_switch首先调用switch_mm切换CR3,然后调用宏switch_to来进行硬件上的上下文切换。
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(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制