Linux进程与程序
原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
学号 093
实验内容
- 从整体上理解进程创建、可执行文件的加载和进程执行进程切换
- 重点理解分析fork、execve和进程切换
实验环境
- 实验楼
实验步骤
一 、阅读task_struct数据结构
什么是进程?
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
计算机为了管理进程,为每个进程都建立了一个结构体来保存与其相关的信息,这个就是PCB。在Linux内核中,PCB是用链式结构存储的,使用一个名为task_struct的结构体来描述。PCB中存了进程的标识符、进程状态、调度优先级、内存指针、上下文数据、信号量、审计信息等诸多关键信息。
二、分析fork函数对应的内核处理过程do_fork()
分析do_fork()源码
1 long do_fork( 2 unsigned long clone_flags, 3 unsigned long stack_start, 4 unsigned long stack_size, 5 int __user *parent_tidptr, 6 int __user *child_tidptr) 8 { 10 struct task_struct *p; 11 int trace = 0; 12 long nr; 14 15 // 复制进程描述符,返回创建的task_struct的指针 16 p = copy_process(clone_flags, stack_start, stack_size, 17 child_tidptr, NULL, trace); 19 if (!IS_ERR(p)) { 20 struct completion vfork; 21 struct pid *pid; 22 trace_sched_process_fork(current, p); 25 // 取出task结构体内的pid 26 pid = get_task_pid(p, PIDTYPE_PID); 27 nr = pid_vnr(pid); 28 if (clone_flags & CLONE_PARENT_SETTID) 29 put_user(nr, parent_tidptr); 31 // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行 32 if (clone_flags & CLONE_VFORK) { 33 p->vfork_done = &vfork; 34 init_completion(&vfork); 35 get_task_struct(p); 36 } 39 // 将子进程添加到调度器的队列,使得子进程有机会获得CPU 40 wake_up_new_task(p); 42 // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间 43 // 保证子进程优先于父进程运行 44 if (clone_flags & CLONE_VFORK) { 45 if (!wait_for_vfork_done(p, &vfork)) 46 ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); 47 } 48 put_pid(pid); 49 } else { 50 nr = PTR_ERR(p); 51 } 52 return nr; 53 }
do_fork()都做了什么?
-
- 调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
- 初始化vfork的完成处理信息(如果是vfork调用)
- 调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
- 如果是vfork调用,需要阻塞父进程,知道子进程执行exec。
进程如何创建?
-
- 通过调用do_fork()来实现进程的创建;
- 复制父进程PCB–task_struct来创建一个新进程,要给新进程分配一个新的内核堆栈;
- 修改复制过来的进程数据,比如pid、进程链表等等执行copy_process和copy_thread
- 成功创建新进程
三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork()
启动实验楼,Linux内核是3.18.6
-
- cd LinuxKernel
- rm menu -rf
- git clone https://github.com/mengning/menu.git
- cd menu
- mv test_fork.c test.c
- make rootfs
进入到gbd调式模式
-
- gdb
- file linux-3.18.6/vmlinux
- target remote:1234
设置断点
-
- b sys_clone
- b do_fork
- b dup_task_struct
- b copy_process
- b copy_thread
- b ret_from_fork
运行后首先停在sys_clone处
再到copy_process
进入copy_thread
do_fork()大概过程fork() -> sys_clone() -> do_fork() -> dup_task_struct() -> copy_process() -> copy_thread() -> ret_from_fork()
新进程是从哪里开始执行的?
函数copy_process中的copy_thread()
1 int copy_thread(unsigned long clone_flags, unsigned long sp, 2 unsigned long arg, struct task_struct *p) 3 { 4 ... 5 *childregs = *current_pt_regs(); 6 childregs->ax = 0; 7 if (sp) 8 childregs->sp = sp; 9 p->thread.ip = (unsigned long) ret_from_fork; 10 ... 11 }
childregs->ax = 0;这段代码将子进程的 eax 赋值为0,使得父进程与子进程分开
为什么从哪里能顺利执行下去?
子进程执行ret_from_fork
1 ENTRY(ret_from_fork) 2 CFI_STARTPROC 3 pushl_cfi %eax 4 call schedule_tail 5 GET_THREAD_INFO(%ebp) 6 popl_cfi %eax 7 pushl_cfi $0x0202 # Reset kernel eflags 8 popfl_cfi 9 jmp syscall_exit 10 CFI_ENDPROC 11 END(ret_from_fork)
p->thread.ip = (unsigned long) ret_from_fork;这句代码将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。因此,函数copy_process中的copy_thread()决定了子进程从系统调用中返回后的执行。
执行起点与内核堆栈如何保证一致?
在ret_from_fork之前,也就是在copy_thread()函数中:*childregs = *current_pt_regs(); 该句将父进程的regs参数赋值到子进程的内核堆栈,*childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数。故在之后的RESTORE ALL中能顺利执行下去。
四、理解编译链接的过程和ELF可执行文件格式
ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库。
-
- 可执行文件(应用程序)可执行文件包含了代码和数据,是可以直接运行的程序。
- 可重定向文件(.o)可重定向文件又称为目标文件,它包含了代码和数据(这些数据是和其他重定位文件和共享的object文件一起连接时使用的)。 .o文件参与程序的连接(创建一个程序)和程序的执行(运行一个程序),它提供了一个方便有效的方法来用并行的视角看待文件的内 容, 这些.o文件的活动可以反映出不同的需要。Linux下,我们可以用gcc -c编译源文件时可将其编译成.o格式。
- 共享文件(*.so)也称为动态库文件,它包含了代码和数据(这些数据是在连接时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为ld.so.1,libc.so.1或者 ld-linux.so.1。在linux下输入“man elf”即可查看其详细的格式定义。
静态链接与动态链接
静态链接
在编译链接时直接将需要的执行代码复制到最终可执行文件中,有点是代码的装在速度块,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对较大。
动态链接
动态链接是在程序运行时由操作系统将需要的动态库加载到内存中。动态链接分为装载时动态链接和运行时动态链接。
五、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve()
exec函数主要工作是根据指定的文件名找到可执行的文件,并用他取代调用进程的内容。换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。
现在我们给出采用exec*库中提供的函数创建简单的程序,先介绍一下exec家族中的函数:
1 (1)int execl(const char *path, const char *arg, ......); 2 3 (2)int execle(const char *path, const char *arg, ...... , char * const envp[]); 4 5 (3)int execv(const char *path, char *const argv[]); 6 7 (4)int execve(const char *filename, char *const argv[], char *const envp[]); 8 9 (5)int execvp(const char *file, char * const argv[]); 10 11 (6)int execlp(const char *file, const char *arg, ......);
他们之间的区别是:
-
- 前四个取路径名做为参数,后两个取文件名做为参数,如果文件名中不包含 “/” 则从PATH环境变量中搜寻可执行文件, 如果找到了一个可执行文件,但是该文件不是连接编辑程序产生的可执行代码文件,则当做shell脚本处理。
- 前两个和最后一个函数中都包括“ l ”这个字母 ,而另三个都包括“ v ”, " l "代表 list即表 ,而" v "代表 vector即矢量,也是是前三个函数的参数都是以list的形式给出的,但最后要加一个空指针,如果用常数0来表示空指针,则必须将它强行转换成字符指针,否则有可能出错。,而后三个都是以矢量的形式给出,即数组。
- 与向新程序传递环境变量有关,如第二个和第四个以e结尾的函数,可以向函数传递一个指向环境字符串指针数组的指针。即自个定义各个环境变量,而其它四个则使用进程中的环境变量。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <unistd.h> 4 #include <string.h> 5 #include <errno.h> 6 7 8 int main(int argc, char *argv[]) 9 { 10 //以NULL结尾的字符串数组的指针,适合包含v的exec函数参数 11 char *arg[] = {"ls", "-a", NULL}; 12 13 /** 14 * 创建子进程并调用函数execl 15 * execl 中希望接收以逗号分隔的参数列表,并以NULL指针为结束标志 16 */ 17 18 if( fork() == 0 ) 19 { 20 // in clild 21 printf( "1------------execl------------\n" ); 22 if( execl( "/bin/ls", "ls","-a", NULL ) == -1 ) 23 { 24 perror( "execl error " ); 25 exit(1); 26 } 27 } 28 29 if( fork() == 0 )//创建子进程,并调用execv函数,execv希望接收到一个以NULL结尾的字符串数组的指针 30 31 { 32 // in child 33 printf("2------------execv------------\n"); 34 if( execv( "/bin/ls",arg) < 0) 35 { 36 perror("execv error "); 37 exit(1); 38 } 39 } 40 return 0; 41 } 42 43 //执行结果 44 1------------execl------------ 45 . .. .deps exec exec.o .libs Makefile 46 2------------execv------------ 47 . .. .deps exec exec.o .libs Makefile
六、总结
-
- 在调度时机方面,内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度。
- schedule()函数实现进程调度,context_ switch完成进程上下文切换,switch_ to完成寄存器的切换。
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度