操作系统原理中,我们通过进程控制块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
3593

被折叠的 条评论
为什么被折叠?



