原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
学号 229
目录
3.使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
5. 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接
6、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解
7 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
8 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
9 使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
10 分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
实验要求:从整理上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析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 阅读理解task_struct数据结构
1.1进程
进程是处于执行期的程序以及它所管理的资源(如打开的文件、挂起的信号、进程状态、地址空间等等)的总称。注意,程序并不是进程,实际上两个或多个进程不仅有可能执行同一程序,而且还有可能共享地址空间等资源。
Linux内核通过一个被称为进程描述符的task_struct结构体来管理进程,这个结构体包含了一个进程所需的所有信息。它定义在include/linux/sched.h文件中。
进程是程序的一个执行实例;
进程是正在执行的程序;
进程是能分配处理器并由处理器执行的实体
在代码中可以看到进程的状态:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
state成员的可能取值如下
/*
* Task state bitmask. NOTE! These bits are also
* encoded in fs/proc/array.c: get_task_state().
*
* We have two separate sets of flags: task->state
* is about runnability, while task->exit_state are
* about the task exiting. Confusing, but this way
* modifying one set can't modify the other one by
* mistake.
*/
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* in tsk->exit_state */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* in tsk->state again */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128 /** wake on signals that are deadly **/
#define TASK_WAKING 256
#define TASK_PARKED 512
#define TASK_NOLOAD 1024
#define TASK_STATE_MAX 2048
/* Convenience macros for the sake of set_task_state */
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
#define TASK_STOPPED (TASK_WAKEKILL | __TASK_STOPPED)
#define TASK_TRACED (TASK_WAKEKILL | __TASK_TRACED)
进程还有5个互斥状态
state域能够取5个互为排斥的值(通俗一点就是这五个值任意两个不能一起使用,只能单独使用)。系统中的每个进程都必然处于以上所列进程状态中的一种。
TASK_RUNNING 表示进程要么正在执行,要么正要准备执行(已经就绪),正在等待cpu时间片的调度
TASK_INTERRUPTIBLE 进程因为等待一些条件而被挂起(阻塞)而所处的状态。这些条件主要包括:硬中断、资源、一些信号一旦等待的条件成立,进程就会从该状态(阻塞)迅速转化成为就绪状态TASK_RUNNING
TASK_UNINTERRUPTIBLE 意义与TASK_INTERRUPTIBLE类似,除了不能通过接受一个信号来唤醒以外,对于处于TASK_UNINTERRUPIBLE 状态的进程,哪怕我们传递一个信号或者有一个外部中断都不能唤醒他们。只有它所等待的资源可用的时候,他才会被唤醒。这个标志很少用,但是并不代表没有任何用处,其实他的作用非常大,特别是对于驱动刺探相关的硬件过程很重要,这个刺探过程不能被一些其他的东西给中断,否则就会让进城进入不可预测的状态
TASK_STOPPED 进程被停止执行,当进程接收到SIGSTOP、SIGTTIN、SIGTSTP或者SIGTTOU信号之后就会进入该状态
TASK_TRACED 表示进程被debugger等进程监视,进程执行被调试程序所停止,当一个进程被另外的进程所监视,每一个信号都会让进城进入该状态
2个终止状态
EXIT_ZOMBIE 进程的执行被终止,但是其父进程还没有使用wait()等系统调用来获知它的终止信息,此时进程成为僵尸进程
EXIT_DEAD 进程的最终状态
进程间的切换过程如下图所示:
1.2进程标识符PID和进程内核栈
在Linux系统中,一个线程组中的所有线程使用和该线程组的领头线程(该组中的第一个轻量级进程)相同的PID,并被存放在tgid成员中。只有线程组的领头线程的pid成员才会被设置为与tgid相同的值。注意,getpid()系统调用返回的是当前进程的tgid值而不是pid值。
内核栈与线程描述符
对每个进程,Linux内核都把两个不同的数据结构紧凑的存放在一个单独为进程分配的内存区域中
一个是内核态的进程堆栈,
另一个是紧挨着进程描述符的小数据结构thread_info,叫做线程描述符。
Linux把thread_info(线程描述符)和内核态的线程堆栈存放在一起,这块区域通常是8192K(占两个页框),其实地址必须是8192的整数倍。
出于效率考虑,内核让这8K空间占据连续的两个页框并让第一个页框的起始地址是213的倍数。
内核态的进程访问处于内核数据段的栈,这个栈不同于用户态的进程所用的栈。
用户态进程所用的栈,是在进程线性地址空间中;
而内核栈是当进程从用户空间进入内核空间时,特权级发生变化,需要切换堆栈,那么内核空间中使用的就是这个内核栈。因为内核控制路径使用很少的栈空间,所以只需要几千个字节的内核态堆栈。
1.3改结构的主要信息
- 进程状态 ,将纪录进程在等待,运行,或死锁
- 调度信息, 由哪个调度函数调度,怎样调度等
- 进程的通讯状况
- 因为要插入进程树,必须有联系父子兄弟的指针, 当然是task_struct型
- 时间信息, 比如计算好执行的时间, 以便cpu 分配
- 标号 ,决定改进程归属
- 可以读写打开的一些文件信息
- 进程上下文和内核上下文
- 处理器上下文
- 内存信息
对应代码如下:
struct task_struct {
volatile long state; //说明了该进程是否可以执行.
//-1表示不能运行,0表示运行,大于0表示停止
unsigned long flags; //进程标志,在调用fork()时给出
int sigpending; //进程上是否有待处理的信号
mm_segment_t addr_limit;//进程地址空间,区分内核进程与普通进程在内存存放的位置不同
//0-0xBFFFFFFF for user-thead
//0-0xFFFFFFFF for kernel-thread
volatile long need_resched; //调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
int lock_depth; //锁深度
long counter; //进程可运行的时间量
long nice; //进程的基本时间片
unsigned long policy; //进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR;分时进程:SCHED_OTHER;
struct mm_struct *mm; //进程内存管理信息
int processor;
unsigned long cpus_runnable, cpus_allowed;//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
struct list_head run_list; //指向运行队列的指针
unsigned long sleep_time; //进程的睡眠时间
struct task_struct *next_task, *prev_task; //用于将系统中所有的进程连成一个双向循环链表,其根是init_task.
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止是向子进程发送的信号
unsigned long personality; //Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
int did_exec : 1; //按POSIX要求设计的布尔量,区分进程正在执行从父进程中继承的代码,还是执行由execve装入的新程序代码
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //标志,表示进程是否为会话主管
struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr;//指针指向(原始的)父进程,孩子进程,比自己年轻的兄弟进程, 比自己年长的兄弟进程
//(p->father能被p->p_pptr->pid代替)
struct list_head thread_group; //线程链表
//进程散列表指针
struct task_struct *pidhash_next; //用于将进程链入HASH表pidhash
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion *vfork_done; // 供vfork() 使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
//it_real_value,it_real_incr用于REAL定时器,单位为jiffies。系统根据it_real_value //设置定时器的第一个终止时间。在定时器到期时,向进程发送SIGALRM信号,同时根据it_real_incr重置终止时间
//it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。当进程运行时,不管在何种状态下,每个tick都使
//it_prof_value值减一,当减到0时,向进程发送信号SIGPROF,并根据it_prof_incr重置时间
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种状态下,每个tick都使
//it_virt_value值减一,当减到0时,向进程发送信号SIGVTALRM,根据it_virt_incr重置初值。
//Real定时器根据系统时间实时更新,不管进程是否在运行
//Virtual定时器只在进程运行时,根据进程在用户态消耗的时间更新
//Profile定时器在进程运行时,根据进程消耗的时(不管在用户态还是内核态)更新
struct timer_list real_timer;//指向实时定时器的指针
struct tms times; //记录进程消耗的时间,
unsigned long start_time;//进程创建的时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS]; //记录进程在每个CPU上所消耗的用户态时间和核心态时间
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换设备读入的页面数);
//nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。在父进程
//回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable : 1; //表示进程的虚拟地址空间是否允许换出
///进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid //euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid, euid, suid, fsuid;
gid_t gid, egid, sgid, fsgid;
int ngroups; //记录进程在多少个用户组中
gid_t groups[NGROUPS]; //记录进程所在的组
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;//进程的权能,分别是有效位集合,继承位集合,允许位集合
int keep_capabilities : 1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned short used_math; //是否使用FPU
char comm[16]; //进程正在运行的可执行文件名
//文件系统信息
int link_count, total_link_count;
struct tty_struct *tty;
unsigned int locks;
//进程间通信信息
struct sem_undo *semundo; //进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
//进程的CPU状态,切换时,要保存到停止进程的
task_struct中
struct thread_struct thread;
struct fs_struct *fs;
//打开文件信息
struct files_struct *files;
//信号处理函数
spinlock_t sigmask_lock;
struct signal_struct *sig; //信号处理函数,
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int(*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;
spinlock_t alloc_lock;
void *journal_info;
}
2 分析fork函数对应的内核处理过程do_fork
Linux系统中,除第一个进程是被捏造出来的,其他进程都是通过do_fork()复制出来的。
do_fork()根据其参数不同,对父进程的复制过程也不同,主要区别是子进程与父进程共享资源还是单独从父进程复制出来一份。
这里是复制父进程全部资源的情况,即复制父进程的task_struct结构、进程用户空间等。
do_fork()函数利用辅助函数copy_process()来创建进程描述符以及子进程所需要的其他所有数据结构。下面是do_fork()函数执行的主要步骤:
1. 通过查找pidmap_array位图,为子进程分配新的PID
2. 检查父进程的ptrace字段:如果它的值不等于0,说明有另外一个进程正在跟踪父进程,因而,do_fork()函数检查debugger程序是否自己想跟踪子进程。在这种情况下,如果子进程不是内核线程(CLONE_UNTRACED标志被清0),则do_fork()函数设置CLONE_PTRACE标志。
3. 调用copy_process()函数复制进程描述符。如果所有必须的资源都是可用的,则该函数返回刚创建的task_struct描述符的地址。
4. 如果设置了CLONE_STOPPED标志,或者必须跟踪子进程,即在p->ptrace 中设置 PT_PTRACED标志,那么子进程的状态被设置成TASK_STOPPED状态,并且为子进程增加挂起的SIGSTOP信号。在另一个进程把子进程状态恢复成TASK_RUNNING之前,一直保持该状态。
5. 如果没有设置CLONE_STOPPED标志,则调用wake_up_new_task(p, clone_flags)函数以执行以下操作:
a.调整父进程和子进程的调度参数
b.如果子进程和父进程运行在同一个CPU上,而且父进程和子进程不能共享同一组页表(CLONE_VM标志被清0),那么,就把子进程插入到父进程的运行队列,插入时让子进程恰好在父进程前面,因此迫使子进程优于父进程先运行。如果子进程刷新其地址空间,并且在创建之后执行新程序,那么这种简单的处理会产生较好的性能。而如果我们让父进程先运行,那么写时复制机制将会执行一些不必要的页面复制。
c.否则,如果子进程与父进程运行在不同CPU上,或者父进程和子进程共享同一组页表(CLONE_VM标志被设置),就把子进程插入父进程所在运行队列的队尾。
6. 如果设置了CLONE_STOPPED标志,则子进程的状态被设置成TASK_STOPPED状态。
7. 如果父进程被跟踪,则把子进程的PID存入current的ptrace_message字段并调用ptrace_notify函数使当前进程停止运行,并向当前进程的父进程发送SIGCHLD信号。子进程的祖父进程是跟踪父进程的debugger进程。SIGCHLD信号通知debugger进程:当前进程current已经创建了一个子进程,可以通过current->ptrace_message字段获得该子进程的PID。
8. 如果设置了CLONE_VFORK标志,则把父进程插入等待队列,并挂起父进程直到子进程释放自己的内存地址空间(也就是说,直到子进程结束或执行新的程序)。
9. 结束并返回子进程的PID。
copy_process()流程
首先调用dup_task_struct()复制当前的task_struct,检查进程数是否超过限制;接着初始化自旋锁、挂起信号、CPU 定时器等;然后调用sched_fork初始化进程数据结构,并把进程状态设置为TASK_RUNNING,复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等;调用copy_thread()初始化子进程内核栈,为新进程分配并设置新的pid。
copy_thread的流程
获取子进程寄存器信息的存放位置对子进程的thread.sp赋值,将来子进程运行,这就是子进程的esp寄存器的值。如果是创建内核线程,那么它的运行位置是ret_from_kernel_thread,将这段代码的地址赋给thread.ip,之后准备其他寄存器信息,退出将父进程的寄存器信息复制给子进程。将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0.子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。
新进程从ret_from_fork处开始执行,子进程的运行是由这几处保证的
dup_task_struct中为其分配了新的堆栈copy_process中调用了sched_fork,将其置为TASK_RUNNINGcopy_thread中将父进程的寄存器上下文复制给子进程,这是非常关键的一步,这里保证了父子进程的堆栈信息是一致的。将ret_from_fork的地址设置为eip寄存器的值,这是子进程的第一条指令。
3.使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
主要执行指令为:
cd LinuxKernel
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
make rootfs
进入gdb调试阶段
在如下位置设置断点
b sys_clone
b do_fork
b dup_task_struct
b copy_process
b copy_thread
b ret_from_for
设置断点:
运行后首先停在sys_clone处:
然后到do_fork:
再到copy_process
进入copy_thread
在copy_thread中,我们可以查看p的值
问题:新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?执行起点与内核堆栈如何保证一致?
1.新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?
函数copy_process中的copy_thread()
int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p)
{
...
*childregs = *current_pt_regs();
childregs->ax = 0;
if (sp)
childregs->sp = sp;
p->thread.ip = (unsigned long) ret_from_fork;
...
}
childregs->ax = 0;这段代码将子进程的 eax 赋值为0
子进程执行ret_from_fork
ENTRY(ret_from_fork)
CFI_STARTPROC
pushl_cfi %eax
call schedule_tail
GET_THREAD_INFO(%ebp)
popl_cfi %eax
pushl_cfi $0x0202 # Reset kernel eflags
popfl_cfi
jmp syscall_exit
CFI_ENDPROC
END(ret_from_fork)
p->thread.ip = (unsigned long) ret_from_fork;这句代码将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。
因此,函数copy_process中的copy_thread()决定了子进程从系统调用中返回后的执行。
2.执行起点与内核堆栈如何保证一致?
在ret_from_fork之前,也就是在copy_thread()函数中:*childregs = *current_pt_regs();
该句将父进程的regs参数赋值到子进程的内核堆栈,*childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数。故在之后的RESTORE ALL中能顺利执行下去。
4、理解编译链接的过程和ELF可执行文件格式
4.1编译链接的过程
4.2 ELF可执行文件格式
一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件。
一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
一个共享object文件保存着代码和合适的数据,用来被不同的两个链接器链接。
流程图:execve–> do——execve –> search_binary_handle –> load_binary
5. 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接
第一步:先编辑一个hello.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Hello World!\n");
return 0;
}
第二步:生成预处理文件hello.cpp(预处理负责把include的文件包含进来及宏替换等工作)
第三步:编译成汇编代码hello.s
第四步:编译成目标代码,得到二进制文件hello.o
第五步:链接成可执行文件hello,(它是二进制文件)
第六步:运行一下./hello
我们也可以静态编译,(是完全把所有需要执行所依赖的东西放到程序内部)
gcc -o hello.static hello.o -m32 -static
hello.static 也是ELF格式文件,运行一下hello.static ./hello.static
然后通过指令 ls -l
发现hello.static (733254)比 hello (7292)大的多。
- 静态链接方式:在程序运行之前完成所有的组装工作,生成一个可执行的目标文件
- 动态链接方式:在程序已经为了执行被装载入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝
动态链接库的两种链接方法
- 装载时动态链接
- 运行时动态链接
6、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解
第一步
首先设置断点:
中断情况如下:
- do_execve代码如下:
int do_execve(struct filename *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 };
//调用do_execve_common
return do_execve_common(filename, argv, envp);
}
7 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
新的可执行程序通过修改内核堆栈eip作为新程序的起点,从new_ip开始执行后start_thread把返回到用户态的位置从int 0x80的下一条指令变成新加载的可执行文件的入口位置。
当执行到execve系统调用时,进入内核态,用execve()加载的可执行文件覆盖当前进程的可执行程序。当execve系统调用返回时,返回新的可执行程序的执行起点(main函数),所以execve系统调用返回后新的可执行程序能顺利执行。execve系统调用返回时,如果是静态链接,elf_entry指向可执行文件规定的头部(main函数对应的位置0x8048***);如果需要依赖动态链接库,elf_entry指向动态链接器的起点。动态链接主要是由动态链接器ld来完成的。
8 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
调用地方:
- 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
- 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
9 使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
首先设几个断点分别是schedule,pick_next_task,context_switch,__switch_t
断点设置情况:
可以看出 schedule调用_schedule,_schedule调用pick_next_task,context_switch函数,context_switch函数调用__switch_to。pick_next_task函数是根据调度策略和调度算法选择下一进程,context_switch函数负责进程的切换。
分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
#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" /* 保存当前进程flags */
43 "pushl %%ebp\n\t" /* 保存当前进程的堆栈基址EBP */
44 "movl %%esp,%[prev_sp]\n\t" /* 保存当前栈顶ESP */
45 "movl %[next_sp],%%esp\n\t" /* 将下一栈栈顶保存到esp中 */
//完成内核堆栈的切换
46 "movl $1f,%[prev_ip]\n\t" /* 保存当前进程的EIP */
47 "pushl %[next_ip]\n\t" /* 把next进程的起点EIP压入堆栈 */
48 __switch_canary
//next_ip一般是$if,对于新创建的子进程是ret_from_fork
49 "jmp __switch_to\n" /* prev进程中,设置next进程堆栈 */
50 "1:\t" //next进程开始执行
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)
首先在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。然后将prev的内核堆栈指针esp存入prev->thread.esp中。
把将next进程的内核栈指针next->thread.esp置入esp寄存器中,将当前进程的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度,通过jmp指令转入一个函数__switch_to,__switch_to中jmp与return的匹配,return 会弹出返回地址,因为jmp不会压栈,return弹出的则是栈顶地址即$1f标识之处。恢复next上次被调离时推进堆栈的内容。next进程开始执行。
进程上下文及与中断上下文切换的关系
进程上下文切换需要保存切换进程的相关信息(thread.sp和thread.ip);中断上下文的切换是在一个进程的用户态到一个进程的内核态,或从进程的内核态到用户态,切换进程需要在不同的进程间切换,但一般进程上下文切换是套在中断上下文切换中的。例如,系统调用作为中断陷入内核,,调用schedule函数发生进程上下文切换,系统调用返回,完成中断上下文的切换。
10 分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
关键函数的调用关系
- schedule() --> context_switch() --> switch_to --> __switch_to(
汇编代码分析:
asm volatile("pushfl\n\t" /* 保存当前进程的标志位 */
"pushl %%ebp\n\t" /* 保存当前进程的堆栈基址EBP */
"movl %%esp,%[prev_sp]\n\t" /* 保存当前栈顶ESP */
"movl %[next_sp],%%esp\n\t" /* 把下一个进程的栈顶放到esp寄存器中,完成了内核堆栈的切换,从此往下压栈都是在next进程的内核堆栈中。 */
"movl $1f,%[prev_ip]\n\t" /* 保存当前进程的EIP */
"pushl %[next_ip]\n\t" /* 把下一个进程的起点EIP压入堆栈 */
__switch_canary
"jmp __switch_to\n" /* 因为是函数所以是jmp,通过寄存器传递参数,寄存器是prev-a,next-d,当函数执行结束ret时因为没有压栈当前eip,所以需要使用之前压栈的eip,就是pop出next_ip。 */
"1:\t" /* 认为next进程开始执行。 */
"popl %%ebp\n\t" /* restore EBP */
"popfl\n" /* restore flags */
/* output parameters 因为处于中断上下文,在内核中
prev_sp是内核堆栈栈顶
prev_ip是当前进程的eip */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip), //[prev_ip]是标号
"=a" (last),
/* clobbered output registers: */
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
__switch_canary_oparam
/* input parameters:
next_sp下一个进程的内核堆栈的栈顶
next_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/
: [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");
} while (0)
switch_to实现了进程之间的真正切换:
首先在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。
然后将prev的内核堆栈指针ebp存入prev->thread.esp中。
把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中
将popl指令所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度
通过jmp指令(而不是call指令)转入一个函数__switch_to()
恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行
11 总结
- 子进程是父进程的副本,子进程复制/拷贝父进程的PCB、数据空间(数据段、堆和栈),父子进程共享正文段(只读)
- 子进程和父进程继续执行fork 函数调用之后的代码,为了提高效率,fork 后不并立即复制父进程数据段、( 堆和栈,采用了写时复制机制(Copy-On-Write) )
- 当父子进程任意之一要修改数据段、堆、栈时,进行复制操作,并且仅复制修改区域。