一. 概述
在最近初学ebpf时,使用到了挂载点finish_task_switch
统计内核线程的运行时间,遂进入内核源码对其进行学习分析。
finish_task_switch
在context_switch
被调用,其功能是完成进程切换的收尾工作,比如地址空间的清理。而context_switch
是进程切换的核心部分,其由两部分组成:
- 切换页全局目录到一个新的地址空间(switch_mm)。
- 切换内核态堆栈及硬件上下文(switch_to)。
context_switch
的代码如下:
static __always_inline struct rq *context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);//执行进程切换的准备工作。
arch_start_context_switch(prev);
if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);//通知处理器架构不需要切换用户虚拟地址空间,这种加速进程切换计数称为惰性TLB
next->active_mm = prev->active_mm;//继承前一个进程的内存描述符
if (prev->mm) // from user
mmgrab(prev->active_mm);//增加前一个进程的活跃地址空间的引用计数,以确保地址空间在进程切换后仍然有效
else //from kernel
prev->active_mm = NULL;
} else { // to user
membarrier_switch_mm(rq, prev->active_mm, next->mm);
switch_mm_irqs_off(prev->active_mm, next->mm, next);//切换地址空间
if (!prev->mm) { // from kernel
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
prepare_lock_switch(rq, next, rf);
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
在分析这段源码之前,我们首先需要知道的是,context_switch( )函数建立next的地址空间。在task struct结构体中有这样两个字段:mm和active_mm。进程描述符的active_mm字段指向进程所使用的内存描述符,而mm字段指向进程所拥有的内存描述符。对于一般的进程,这两个字段有相同的地址,但是,内核线程没有它自己的地址空间而且它的 mm字段总是被设置为 NULL。
context_switch( )函数实现:如果next是一个内核线程,那么它使用prev所使用的地址空间。
具体实现流程:
prepare_task_switch
:执行进程切换的准备工作,包括处理器架构相关的准备工作。- 判断
next
进程是否有内存描述符(即是否指向内核空间):
membarrier_switch_mm
:切换地址空间,确保内存访问的一致性。switch_mm_irqs_off
:切换内存映射表,并关闭中断来保证原子性。- 如果前一个进程是内核线程,则将其活跃地址空间保存在
rq->prev_mm
中,并将prev->active_mm
置为NULL。 enter_lazy_tlb
:通知处理器架构不需要切换用户虚拟地址空间,使用惰性TLB加速进程切换,懒惰TLB模式是为了减少无用的TLB刷新。next->active_mm = prev->active_mm
:将上一个进程的内存描述符赋值给next
,继承其用户虚拟地址空间。- 调用mmgrab增加mm->mm_count引用计数,以确保地址空间在进程切换后仍然有效。
- 如果没有内存描述符,说明next是内核线程,需要借用上一个进程的用户虚拟地址空间。此时会执行以下操作:
- 如果有内存描述符,说明next是用户进程。此时会执行以下操作:
switch_to
:进行真正的进程切换,将控制权从prev
进程切换到next
进程,与体系架构相关。finish_task_switch
:完成进程切换的收尾工作,并返回前一个进程的task_struct
结构,如果prev是内核线程,则调用mmdrop减少内存描述符引用计数。如果引用计数为0,则释放与页表相关的所有描述符和虚拟内存。
二.switch_mm
对于用户进程需要完成用户空间的切换,switch_mm_irqs_off函数完成了这个任务,其是与体系架构相关的。
ARM架构下的进程地址空间切换实际上是通过设置页表基址寄存器TTBR0来完成的。每个进程拥有整个系统的虚拟地址空间,但并不会真正占用所有的物理地址空间,而是需要通过页表转换来完成对物理地址的访问。页表转换的基址信息存放在页表基址寄存器TTBR0中。
TTBR0寄存器指示了进程页全局目录表(PGD)的基址,PGD又指示了页表项(PTE)的基址,而PTE则指示了对应的物理地址(PA)。由于每个进程的PGD是不同的,因此不同进程的虚拟内存对应的物理地址被隔离开来。实质上,进程切换就是完成了对TTBR0寄存器的重新设置,以切换到新进程的地址空间。
进程地址空间ASID
switch_mm_irqs_off函数中最主要的一个函数是check_and_switch_context(),完成与体系结构相关的硬件设置。MMU在做地址翻译时,需要访问物理内存中的页表映射,每一级页表映射都需要访问一次内存,而内存的访问对性能影响很大,因而效率很低。TLB(Translation Lookaside Buffer)是用于缓存MMU地址转换结果的cache,访问cache找到物理地址比访问内存找物理地址快的多,因而TLB加快内存的访问效率。
从Linux 内核角度看,地址空间可以划分为内核地址空间和用户空间,TLB 可以分成全局类型和进程独有类型。
全局类型的 TLB:内核空间是所有进程共享的空间,因此这部分空间的虚拟地址到物园理地址的翻译是不会变化的,可以理解为全局的。
进程独有类型的 TLB:用户地址空间是每个进程独立的地址空间。从 prev 进程切换到next 进程时,TLB 中缓存的 prev 进程的相关数据对于 next 进程是无用的,因此可以刷新。
为了支持进程独有类型的 TLB,ARM 架构出现了一种硬件解决方案,叫作进程地址空间**ASID(Address Space ID)**,通过使每个表项包含一个ASID,TLB 可以识别哪些 TLB 项是属于某个进程的。
ASID标识了每个TLB entry所属的进程,这样可以保证不同进程之间的TLB entry不会互相干扰,因而避免了切换进程时将TLB刷新的问题。所以ASID作用避免了进程切换时TLB的频繁刷新。
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
学习直通车:Linuxc/c++高级开发【直播公开课】
零声白金VIP体验卡:零声白金VIP体验卡(含基础架构/高性能存储/golang/QT/音视频/Linux内核)
三.switch_to
switch_to函数完成了内核空间及寄存器的切换,switch_to调用到__switch_to,其代码如下:
#define switch_to(prev,next,last)
do {
__complete_pending_tlbi();
if (IS_ENABLED(CONFIG_CURRENT_POINTER_IN_TPIDRURO) || is_smp())
__this_cpu_write(__entry_task, next);
last = __switch_to(prev,task_thread_info(prev), task_thread_info(next));
} while (0)
__switch_to汇编实现如下,三个参数分别为:
**r0:移出进程prev的task_struct、r1:移出进程prev的thread_info、r2:**移入进程next的thread_info
ENTRY(__switch_to)
UNWIND(.fnstart )
UNWIND(.cantunwind )
add ip, r1, #TI_CPU_SAVE
ARM( stmia ip!, {r4 - sl, fp, sp, lr} ) @ Store most regs on stack
THUMB( stmia ip!, {r4 - sl, fp} ) @ Store most regs on stack
THUMB( str sp, [ip], #4 )
THUMB( str lr, [ip], #4 )
ldr r4, [r2, #TI_TP_VALUE]
ldr r5, [r2, #TI_TP_VALUE + 4]
#ifdef CONFIG_CPU_USE_DOMAINS
mrc p15, 0, r6, c3, c0, 0 @ Get domain register
str r6, [r1, #TI_CPU_DOMAIN] @ Save old domain register
ldr r6, [r2, #TI_CPU_DOMAIN]
#endif
switch_tls r1, r4, r5, r3, r7
#if defined(CONFIG_STACKPROTECTOR) && !defined(CONFIG_SMP) && \
!defined(CONFIG_STACKPROTECTOR_PER_TASK)
ldr r8, =__stack_chk_guard
.if (TSK_STACK_CANARY > IMM12_MASK)
add r9, r2, #TSK_STACK_CANARY & ~IMM12_MASK
ldr r9, [r9, #TSK_STACK_CANARY & IMM12_MASK]
.else
ldr r9, [r2, #TSK_STACK_CANARY & IMM12_MASK]
.endif
#endif
mov r7, r2 @ Preserve 'next'
#ifdef CONFIG_CPU_USE_DOMAINS
mcr p15, 0, r6, c3, c0, 0 @ Set domain register
#endif
mov r5, r0
add r4, r2, #TI_CPU_SAVE
ldr r0, =thread_notify_head
mov r1, #THREAD_NOTIFY_SWITCH
bl atomic_notifier_call_chain
#if defined(CONFIG_STACKPROTECTOR) && !defined(CONFIG_SMP) && \
!defined(CONFIG_STACKPROTECTOR_PER_TASK)
str r9, [r8]
#endif
mov r0, r5
#if !defined(CONFIG_THUMB2_KERNEL) && !defined(CONFIG_VMAP_STACK)
set_current r7, r8
ldmia r4, {r4 - sl, fp, sp, pc} @ Load all regs saved previously
#else
mov r1, r7
ldmia r4, {r4 - sl, fp, ip, lr} @ Load all regs saved previously
#ifdef CONFIG_VMAP_STACK
ldr r2, [ip]
#endif
set_current r1, r2
mov sp, ip
ret lr
#endif
UNWIND(.fnend )
ENDPROC(__switch_to)
我们分析几句关键性的语句:
add ip, r1, #TI_CPU_SAVE
这句话将IP寄存器赋值为r1+ TI_CPU_SAVE,r1即刚刚传入的参数prev->thread_info,TI_CPU_SAVE是cpu_context成员在thread_info中的偏移,接下来要将当前的寄存器值保存在这里。
我们来看看cpu_context是什么,它描述了一个进程切换时,CPU所需要保存的寄存器,也称为硬件上下文,ARM体系下的cpu_context保存了以下寄存器,将上次next进程保存的cpu_context的值恢复到硬件寄存器中,就完成了进程的切换。
struct cpu_context {
unsigned long x19;
unsigned long x20;
unsigned long x21;
unsigned long x22;
unsigned long x23;
unsigned long x24;
unsigned long x25;
unsigned long x26;
unsigned long x27;
unsigned long x28;
unsigned long fp;
unsigned long sp;
unsigned long pc;
};
接着往下看。
ARM( stmia ip!, {r4 - sl, fp, sp, lr} )
这是将r4 - sl, fp, sp, lr寄存器中的内容保存到IP寄存器所指向的内存地址,即prev->thread_info->cpu_context,这相当于保存了prev进程运行时的寄存器上下文。
接下来都是在做将寄存器保存到内存,内存地址不断递增,且回写到IP寄存器。
add r4, r2, #TI_CPU_SAVE
这句话实现r4寄存器保存了next->thread_info->cpu_context的地址。
ldmia r4, {r4 - sl, fp, sp, pc}
ldmia r4, {r4 - sl, fp, ip, lr}
这是将next->thread_info->cpu_context的数据加载到r4 - sl, fp, sp, lr,pc寄存器中。next->thread_info->cpu_context->sp存入寄存器,相当于内核栈切换完成,next->thread_info->cpu_context->pc存入寄存器PC,相当于跳转到next进程运行。
4.完成切换
在经历以上步骤后,CPU上执行的进程已经变成了next,由它执行finish_task_switch
,完成切换后的清理工作,比如当之前的 mm 不再被引用时,将其释放掉,如果上一个进程的状态为 DEAD,需要释放掉上一个进程的相关资源,同时还会打开在 schedule 前期禁止的中断。
5.进程切换调用图示
小结
作为一个学习eBPF技术的初学者,我觉得要想深入了解操作系统的性能数据捕获机制,是必须要深入研究内核源码的。因为只有了解挂载点的执行时机,才能更好地理解eBPF在内核中的运行机制。
我发现将学习eBPF技术与深入研究内核知识相结合是非常明智的选择。通过这样的方式不仅可以掌握eBPF的技术细节,还可以深入了解操作系统的内部工作原理。这次学习让我获得了丰富的经验和知识,同时也加深了我对操作系统内部工作原理的认识。
原文作者:张新谊