原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
一. 实验要求:从整体上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
- 阅读理解task_struct数据结构http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235;
- 分析fork函数对应的内核处理过程do_fork,理解创建一个新进程如何创建和修改task_struct数据结构;
- 使用gdb跟踪分析一个fork系统调用内核处理函数do_fork ,验证您对Linux系统创建一个新进程的理解,特别关注新进程是从哪里开始执行的?为什么从那里能顺利执行下去?即执行起点与内核堆栈如何保证一致。
- 理解编译链接的过程和ELF可执行文件格式;
- 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接;
- 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解;
- 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
- 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
- 使用gdb跟踪分析一个schedule()函数 ,验证您对Linux系统进程调度与进程切换过程的理解;
- 特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系。
二. 实验内容
1. 创建线程相关系统调用
关于进程的创建,Linux提供了几个系统调用来创建和终止进程,以及执行新程序,他们分别是fork,vfork,clone和exec,exit;其中clone用来创建轻量级进程,必须制定要共享的资源,exec系统调用执行一个新程序,exit系统调用终止进程。不论是fork,vfork还是clone,在内核中最终都是调用了do_fork来实现进程的创建。
调用关系图
一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
进程创建四要素:
1、 有一段程序供其执行;
2、 有进程专用的系统堆栈空间,即内核栈;
3、 有进程控制块task_struct结构体;
4、 有独立的存储空间,专用的用户空间,即用于虚存管理的mm_struct结构、下属vm_area结构,以及相应的页面目录项和页面表,都从属于task_struct结构。
在include/linux/sched.h中定义了task_struct结构:PCB是进程存在和运行的唯一标识,在进程控制块task_struct结构中主要包含进程的状态、性质、资源和组织。
fork()是全部复制,父进程所有资源都通过数据结构的复制“遗传”给子进程;vfork()除了task_struct结构和系统空间堆栈以外的资源全部通过数据结构指针的复制“遗传”给子进程;而clone()则将资源有选择的复制给子进程,没有复制的数据结构则通过指针的复制让子进程共享。
下面来看看fork函数的源码,通过源码了解do_fork()的执行过程:
1 long do_fork(unsigned long clone_flags, 2 unsigned long stack_start, 3 unsigned long stack_size, 4 int __user *parent_tidptr, 5 int __user *child_tidptr) 6 { 7 struct task_struct *p; //创建进程描述符指针 8 int trace = 0; 9 long nr; //子进程pid 10 11 ... 12 //创建子进程的描述符和执行时所需的其他数据结构 13 p = copy_process(clone_flags, stack_start, stack_size, 14 child_tidptr, NULL, trace); 15 16 if (!IS_ERR(p)) { //copy_process执行成功 17 struct completion vfork; //定义完成量(一个执行单元等待另一个执行单元完成) 18 19 trace_sched_process_fork(current, p); 20 21 nr = task_pid_vnr(p); //获取pid 22 23 ... 24 //如果clone_flags包含CLONE_VFORK标识,将vfork完成量赋给进程描述符 25 if (clone_flags & CLONE_VFORK) { 26 p->vfork_done = &vfork; 27 init_completion(&vfork); 28 get_task_struct(p); 29 } 30 31 wake_up_new_task(p); //将子进程添加到调度器的队列 32 33 ... 34 35 //这个函数的作用是在进程创建的最后阶段,父进程会将自己设置为不可中断状态,然后睡眠在 等待队列上(init_waitqueue_head()函数 就是将父进程加入到子进程的等待队列),等待子进程的唤醒。 36 if (clone_flags & CLONE_VFORK) { 37 if (!wait_for_vfork_done(p, &vfork)) 38 ptrace_event(PTRACE_EVENT_VFORK_DONE, nr); 39 } 40 } else { 41 nr = PTR_ERR(p); //错误处理 42 } 43 return nr; //返回子进程pid(此处的pid为子进程的pid) 44 }
do_fork()主要完成了调用copy_process()复制父进程的相关信息,调用wake_up_new_task函数将子进程加入到调度器队列中。do_fork() 生成一个新的进程,大致分为三个步骤 :
1. 建立进程控制结构并赋初值,使其成为进程映像。
2. 为新进程的执行设置跟踪进程执行情况的相关内核数据结构。包括 任务数组、自由时间列表 tarray_freelist 以及 pidhash[] 数组。
3. 启动调度程序,使子进程获得运行的机会。
在第一个步骤中,首先申请一个 task_struct 数据结构,表示即将生成的新进程。通过检查clone_flags的值,确定接下来需要做的操作。通过copy_process这个函数,把父进程 PCB 直接复制到新进程的 PCB 中。为新进程分配一个唯一的进程标识号 PID 和 user_struct 结构。
在第二个步骤中,首先把新进程加入到进程链表中, 把新进程加入到 pidhash 散列表中,并增加任务计数值。通过拷贝父进程的上、下文来初始化硬件的上下文(TSS段、LDT以及 GDT)。
在第三个步骤中,设置新的就绪队列状态 TASK_RUNING , 并将新进程挂到就绪队列中,并重新启动调度程序使其运行,向父进程返回子进程的 PID,设置子进程从 do_fork() 返回 0 值。
do_fork()
首先调用copy_process()为子进程复制出一份进程信息,如果是vfork()则初始化完成处理信息;
然后调用wake_up_new_task将子进程加入调度器,为之分配CPU,如果是vfork(),则父进程等待子进程完成exec替换自己的地址空间。
copy_process()
首先调用dup_task_struct()复制当前的task_struct,检查进程数是否超过限制;
接着初始化自旋锁、挂起信号、CPU 定时器等;
然后调用sched_fork初始化进程数据结构,并把进程状态设置为TASK_RUNNING,复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等;
调用copy_thread()初始化子进程内核栈,为新进程分配并设置新的pid。
dup_task_struct()
调用alloc_task_struct_node()分配一个 task_struct 节点;
调用alloc_thread_info_node()分配一个 thread_info 节点,其实是分配了一个thread_union联合体,将栈底返回给 ti;
最后将栈底的值 ti 赋值给新节点的栈。
copy_thread()
获取子进程寄存器信息的存放位置
对子进程的thread.sp赋值,将来子进程运行,这就是子进程的esp寄存器的值。
如果是创建内核线程,那么它的运行位置是ret_from_kernel_thread,将这段代码的地址赋给thread.ip,之后准备其他寄存器信息,退出
将父进程的寄存器信息复制给子进程。
将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0.
子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。
调用流程图
2. GDB跟踪结果:
2. 可执行程序加载过程的理解
一个源码,需要成为可执行文件之前,需要经历预处理、编译、汇编、链接等几个步骤【以hello.c为例】。
预处理:gcc -E hello.c -o hello.i 。
预处理时,编译器主要的工作有:删除所有注释“//”、“/**/”;删除所有的“#define”,展开所有的宏定义;处理所有的条件预编译指令;处理“#include”预编译指令,将被包含的文件插入该预编译指令的位置;添加行号和标识。处理完之后,hello.i属于文本文件。
编译:gcc -S hello.i -o hello.s -m32 。(-S:只编译,不进行汇编;-m32:生成32位平台格式文件,与64位平台使用不同的寄存器名和指令集)
编译过程中,gcc首先检查代码的正确性和规范性,以进一步确定代码实际要完成的工作。在检查无误之后,gcc把代码翻译成汇编语言。hello,s任然是文本文件。
汇编:gcc -c hello.s -o hello.o.m32 -m32
汇编结束后生成的.o文件为ELF格式的文件。目标文件至少含有三个3个节区(Section),分别是.text、.data和.bss。.bss段(bss segment)(BlockStarted by Symbol),主要是用来存放程序中未初始化的全局变量的一块内存区域,属于静态内存分配,当程序开始运行时,系统将用0来初始化这片内存区域;.data段(data segment,数据段),通常是指用来存放程序中已经初始化的全局变量的一块内存区域,属于静态分配;.text段(code segment/text segment),通常指用来存放程序执行代码的一块内存区域,内存区域大小在程序运行前就已经确定,通常属于只读。除了上述的3个主要的节区之外,还有一些其他常见的节:用于存放C中的字符串和#define定义的常量,包含着只读数据的.rodata段;包含版本控制信息的.comment段;包含动态链接信息的.dymanic段;包含动态链接符号表的.dynsym段;包含用于初始化进程的可执行代码.init段,也就是执行main函数之前需要执行的部分程序。
3. 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve
在Linux中,对于一个可执行文件的执行,可以通过的方式主要有:execl、execlp、execle、execv、exexvp、execve等6个函数,其使用差异主要体现在命令行参数和环境变量参数的不同,这些函数最终都需要调用系统调用函数sys_execve()来实现执行可执行文件的目的。
sys_execve()源码如下:
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { struct filename *path = getname(filename); int error = PTR_ERR(path); if (!IS_ERR(path)) { error = do_execve(path->name, argv, envp); putname(path); } return error; }
do_execve()函数代码如下:
int do_execve(const char *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct user_arg_ptr argv = { .ptr.native = __argv }; struct user_arg_ptr envp = { .ptr.native = __envp }; return do_execve_common(filename, argv, envp); }
do_execve_common()函数代码如下:
/* * sys_execve() executes a new program. */ static int do_execve_common(const char *filename, struct user_arg_ptr argv, struct user_arg_ptr envp) { struct linux_binprm *bprm; struct file *file; struct files_struct *displaced; bool clear_in_exec; int retval; const struct cred *cred = current_cred(); ... file = open_exec(filename); //打开要加载的可执行文件,加载文件头部,判断文件类型 ... //创建一个bprm结构体,把环境变量和命令行参数都复制到结构体中 bprm->file = file; bprm->filename = filename; bprm->interp = filename; ... //把传入的shell上下文复制到bprm中 retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out; //把传入的命令行参数复制到bprm中 retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out; //根据读入的文件头部,寻找可执行文件的处理函数 retval = search_binary_handler(bprm); ... }
追踪sys_execve的过程:
我们开始跟踪执行过程,在qemu中输入exec,首先进入断点是sys_execve,在在中端处理程序中,调用了do_execve(),其中getname从用户空间获取filename(也就是hello)的路径,到内核中。
进入do_execve函数体内发现,实际工作是完成argv envp赋值,然后调用do_execve_common
我们进入do_execve_common函数体内:
我们跟踪到do_execve_common,do_execve_common完成了一个linux_binprm的结构体 bprm的初始化工作,然后我们跟踪到exec_binprm,查看函数代码,这段代码将父进程的pid保存,获取新的pid,然后执行search_binary_hander(bprm),用来遍历format链表。
在找到可以解析当前可执行文件的代码之后,会调用start_kernel()开启一个新的进程进行可执行文件的执行。
综合上述的理解,对于系统调用sys_execve的系统调用的主题流程为:sys_execve() -> do_execve() -> do_execve_common() -> search_binary_handler() ->start_thread()。
4. Linux内核中进程调度的时机
-
中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();
-
内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
-
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
每个时钟中断(timer interrupt)发生时,由三个函数协同工作,共同完成进程的选择和切换,它们是:schedule()、do_timer()及ret_form_sys_call()。我们先来解释一下这三个函数:
schedule():进程调度函数,由它来完成进程的选择(调度);
do_timer():暂且称之为时钟函数,该函数在时钟中断服务程序中被调用,是时钟中断服务程序的主要组成部分,该函数被调用的频率就是时钟中断的频率即每秒钟100次(简称100赫兹或100Hz);
ret_from_sys_call():系统调用返回函数。当一个系统调用或中断完成时,该函数被调用,用于处理一些收尾工作,例如信号处理、核心任务等等。
__schedule()函数源码如下:
static void __sched __schedule(void) { struct task_struct *prev, *next; unsigned long *switch_count; struct rq *rq; int cpu; need_resched: preempt_disable(); cpu = smp_processor_id(); rq = cpu_rq(cpu); rcu_note_context_switch(cpu); prev = rq->curr; schedule_debug(prev); if (sched_feat(HRTICK)) hrtick_clear(rq); raw_spin_lock_irq(&rq->lock); switch_count = &prev->nivcsw; if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { if (unlikely(signal_pending_state(prev->state, prev))) { prev->state = TASK_RUNNING; } else { deactivate_task(rq, prev, DEQUEUE_SLEEP); prev->on_rq = 0; /* * If a worker went to sleep, notify and ask workqueue * whether it wants to wake up a task to maintain * concurrency. */ if (prev->flags & PF_WQ_WORKER) { struct task_struct *to_wakeup; to_wakeup = wq_worker_sleeping(prev, cpu); if (to_wakeup) try_to_wake_up_local(to_wakeup); } } switch_count = &prev->nvcsw; } pre_schedule(rq, prev); if (unlikely(!rq->nr_running)) idle_balance(cpu, rq); put_prev_task(rq, prev); next = pick_next_task(rq); clear_tsk_need_resched(prev); rq->skip_clock_update = 0; if (likely(prev != next)) { rq->nr_switches++; rq->curr = next; ++*switch_count; context_switch(rq, prev, next); /* unlocks the rq */ /* * The context switch have flipped the stack from under us * and restored the local variables which were saved when * this task called schedule() in the past. prev == current * is still correct, but it can be moved to another cpu/rq. */ cpu = smp_processor_id(); rq = cpu_rq(cpu); } else raw_spin_unlock_irq(&rq->lock); post_schedule(rq); sched_preempt_enable_no_resched(); if (need_resched()) goto need_resched; }
switch_to中的汇编代码分析:
schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用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 { /* * Context-switching clobbers all registers, so we clobber * them explicitly, via unused output variables. * (EAX and EBP is not listed because EBP is saved/restored * explicitly for wchan access and EAX is the return value of * __switch_to()) */ unsigned long ebx, ecx, edx, esi, edi; asm volatile("pushfl " /* save flags */ "pushl %%ebp " /* save EBP */ 当前进程堆栈基址压栈 "movl %%esp,%[prev_sp] " /* save ESP */ 将当前进程栈顶保存prev->thread.sp "movl %[next_sp],%%esp " /* restore ESP */ 讲下一个进程栈顶保存到esp中 "movl $1f,%[prev_ip] " /* save EIP */ 保存当前进程的eip "pushl %[next_ip] " /* restore EIP */ 将下一个进程的eip压栈,next进程的栈顶就是他的的起点 __switch_canary "jmp __switch_to " /* regparm call */ "1: " "popl %%ebp " /* restore EBP */ "popfl " /* restore flags */ 开始执行下一个进程的第一条命令 /* output parameters */ : [prev_sp] "=m" (prev->thread.sp), [prev_ip] "=m" (prev->thread.ip), "=a" (last), /* clobbered output registers: */ "=b" (ebx), "=c" (ecx), "=d" (edx), "=S" (esi), "=D" (edi) __switch_canary_oparam /* input parameters: */ : [next_sp] "m" (next->thread.sp), [next_ip] "m" (next->thread.ip), /* regparm parameters for __switch_to(): */ [prev] "a" (prev), [next] "d" (next) __switch_canary_iparam : /* reloaded segment registers */ "memory"); 77} while (0)
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行,所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。
总结
中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据 need_resched 标记调用schedule()。
内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度。
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。
运行在进程上下文的内核代码是可以被抢占的(Linux2.6支持抢占)。但是一个中断上下文,通常都会始终占有CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制。