Linux操作系统分析 进程的调度与切换

操作系统原理中,我们通过进程控制块PCB描述进程。Linux中具体使用struct task_struct结构体来描述进程结构。如下代码摘录了 struct task_struct 数据结构的一部分,具体见include/linux/sched.h

struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
    /*
     * For reasons of header soup (see current_thread_info()), this
     * must be the first element of task_struct.
     */
    struct thread_info        thread_info;
#endif
    /* -1 unrunnable, 0 runnable, >0 stopped: */
    volatile long  state;/可以理解为操作系统的进程状态如就绪,等待,阻塞态,不过实际Linux中并不使用这三个状态/

    void  *stack;/内核栈指针,注意用户栈和内核栈是独立的空间/
...
  
    /* CPU-specific state of this task: */
    /thread_struct thread这个变量保存进程切换的关键信息,主要是ip指令指针和sp栈顶指针/
    struct thread_struct thread; 
    struct mm_struct		*mm;
	/用户虚拟内存空间描述符/

    /*
     * WARNING: on x86, 'thread_struct' contains a variable-sized
     * structure.  It *MUST* be at the end of 'task_struct'.
     *
     * Do not put anything below here!
     */
...........
    /初次之外还有文件系统fs的描述,进程间通信的信号signal的描述等/

进程切换就是切换cpu上下文。尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复一个进程执行之前,内核必须确保每个寄存器装入了挂起进程时的值。

进程上下文主要有三个部分构成

用户地址空间:包括程序代码、数据、用户堆栈等。

控制信息:进程描述符、内核堆栈等。

 进程的CPU上下文,相关寄存器的值。

schedule()完成进程调度和切换

进程的切换和调度调用schedule()函数完成,schedule() 函数只是个外层的封装,实际调用的还是 __schedule() 函数。该函数执行大致分为两大步,一是从就绪队列rq中选择一个进程(pick_next_task),由进程调度算法决定选择哪一个进程作为下一个进程(next);二是完成进程上下文切换context_switch,进程上下文包含了进程执行需要的所有信息。

static void __sched notrace __schedule(bool preempt)
{
    struct task_struct *prev, *next;
    ...
next = pick_next_task(rq, prev, &rf);
    ...
    rq = context_switch(rq, prev, next, &rf);
    ...
}

Linux内核中由context_switch实现了 上下文切换。

context_switch关键代码如下

static inline void  
context_switch(struct rq *rq, struct task_struct *prev,  
       struct task_struct *next)  
{  
... 
    if (unlikely(!mm)) { /* 如果被切换进来的进程的mm为空切换,内核线程mm为空 */  
        next->active_mm = oldmm; /* 将共享切换出去进程的active_mm */  
        atomic_inc(&oldmm->mm_count); /* 有一个进程共享,所有引用计数加一 */  
        /* 将per cpu变量cpu_tlbstate状态设为LAZY */  
        enter_lazy_tlb(oldmm, next);  
    } else  /* 普通mm不为空,则调用switch_mm切换地址空间 */  
        switch_mm(oldmm, mm, next);  
...
    /* 这里切换寄存器状态和栈 */  
    switch_to(prev, next, prev);  
...
}

调用switch_mm完成用户空间切换;
调用switch_to完成内核栈及寄存器切换。

switch_mm()用户地址空间切换

进程无法直接访问到物理内存,而是通过虚拟内存到物理内存的映射机制间接访问到物理内存的。每个进程都有自己独立的虚拟内存地址空间,进程通过多级页表机制来执行虚拟内存到物理内存的映射。如下所示分级解析中对48bit虚拟地址的划分。

通过TLB高速缓存缓存虚拟地址和其映射的物理地址。有了TLB之后,MMU把虚拟地址转换成物理地址的过程首先就是查询TLB高速缓存,没有命中的话再一级一级查询4级页表。

内存描述符mm_struct描述了进程了空间,通用mm存放在进程描述符task_struct中,

 我们可以看下mm_struct的结构

struct mm_struct {
    struct vm_area_struct *mmap;		/* list of VMAs */
	u64 vmacache_seqnum;                   /* per-thread vmacache */
    unsigned long task_size;    / 表示用户态内存空间的大小/
    unsigned long start_code, end_code, start_data, end_data;/代码块起始位置和结束位置/
    unsigned long start_brk, brk, start_stack; /堆的起始和结束位置/
    unsigned long arg_start, arg_end, env_start, env_end;
    unsigned long mmap_base;  /* base of mmap area */
    unsigned long total_vm;    /* Total pages mapped */
    unsigned long locked_vm;  /* Pages that have PG_mlocked set */
    unsigned long pinned_vm;  /* Refcount permanently increased */
    unsigned long data_vm;    /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
    unsigned long exec_vm;    /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
    unsigned long stack_vm;    /* VM_STACK */
    ...................
}

用户地址空间切换其实就是切换新进程的页表和tlb缓存。这里看到切换用户空间的switch_mm()

void switch_mm(mm_struct *prev, mm_struct *next) {
    // 如果两个进程不是同一个进程
    if (prev != next)
        __switch_mm(next);
    ...
}

// arch/arm64/include/asm/mmu_context.h:224
void __switch_mm(struct mm_struct *next) {
    unsigned int cpu = smp_processor_id();
    check_and_switch_context(next, cpu);
}

接下来,调用 check_and_switch_context 做实际的虚拟内存切换操作:

void check_and_switch_context(struct mm_struct *mm, unsigned int cpu) {
    ...
    u64 asid;

    // 获取下一个进程的asid
    asid = atomic64_read(&mm->context.id); // 
    ...
    // 绑定asid到当前cpu
    atomic64_set(&per_cpu(active_asids, cpu), asid);  // 

    // 切换页表,mm->pgd是我们上文中的一级页表
    cpu_switch_mm(mm->pgd, mm); // 

。

将下一个进程的 ASID 绑定到当前的 CPU,这样 TLB 通过虚拟地址翻译出来的物理地址,就属于下个进程的。

拿到下一个进程页表,对应的字段是 mm->pgd,然后执行页表切换逻辑,这样后续如果 TLB 没命中,当前 CPU 就能够知道通过哪个页表来翻译虚拟地址

switch_to()内核栈和寄存器切换

static inline void  
context_switch(struct rq *rq, struct task_struct *prev,  
       struct task_struct *next)  
{  
... 
    if (unlikely(!mm)) { /* 如果被切换进来的进程的mm为空切换,内核线程mm为空 */  
        next->active_mm = oldmm; /* 将共享切换出去进程的active_mm */  
        atomic_inc(&oldmm->mm_count); /* 有一个进程共享,所有引用计数加一 */  
        /* 将per cpu变量cpu_tlbstate状态设为LAZY */  
        enter_lazy_tlb(oldmm, next);  
    } else  /* 普通mm不为空,则调用switch_mm切换地址空间 */  
        switch_mm(oldmm, mm, next);  
...
    /* 这里切换寄存器状态和栈 */  
    switch_to(prev, next, prev);  
...
}

 回到我们的context_switch(),在完成了用户空间切换switch_mm后,调用switch_to完成内核堆栈和cpu上下文的切换。

代码解释如下

//此时的内核堆栈仍为prev进程的内核栈
pushfl //压栈eflags寄存器
pushl %ebp  //压栈prev的ebp
prev->thread.sp=%esp

//将当前esp(prev的内核栈顶)保存到prev的进程描述符的中


%esp=next->thread.sp //此时esp已经是next进程的栈顶,但是ebp和ip尚未切换

prev->thread.ip=$1f  //将prev的描述符中的ip置为$1f

push next->thread.ip //压栈next->thread.ip
jmp _switch_to     

//跳转到_switch_to代码,注意时jmp不是call

 ..............

1f:          
popl %%ebp        
popfl 


注意switch_to是在C代码中调用的,也就是使用call指令,而这段汇编的结尾是jmp __switch_to,__switch_to函数是C代码最后有个return,也就是ret指令。

将switch_to和__switch_to结合起来,正好是call指令和ret指令的配对出现。

call指令压栈RIP寄存器到进程切换前的prev进程内核堆栈;而ret指令出栈存入RIP寄存器的是进程切换之后的next进程的内核堆栈栈顶数据。

这段话怎么理解,我们看 __switch_to执行完成后肯定执行一个ret指令,这个ret指令将我们栈顶值赋给cpu的ip寄存器,我们当前的栈顶值正好是next->thread.ip(之前前的指令push next->thread.ip ),而next->thread.ip的值一定是1f.(我们将prev调度出去之前是不是将prev->thread.ip置为了1f,而此时的next正是之前的prev),跳转到1f后弹出next堆栈之前保存的ebp和elfags,之前内核堆栈的ebp和elfags是在哪里保存的,注意switch_to起始的pushfl ,pushl %ebp  这里是保存的prev的ebp和eflags到prev的内核堆栈。当prev再次被调度的时候prev变成了next。

至此进程切换完毕

参考资料

《庖丁解牛Linux操作系统分析》 https://gitee.com/mengning997/linuxkernel

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值