379
原创作品转载请注明出处
本实验来源 https://github.com/mengning/linuxkernel/
内核代码版本 linux-5.0
进程的创建
进程描述符的介绍
在http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235中的第1235行我们可以找到struct task_struct结构,它有数百行代码,所以看机构图更加直观。
图片来自《庖丁解牛linux内核分析》
fork函数创建进程的实质是子进程对父进程的复制,首先我们写一个简单的使用fork系统调用的程序集成到menuOS中(关于menuOS请参考前面两篇博客)
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目标文件格式
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(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制