linux内核进程切换(二) - 进程管理(十九)

在进程创建时,内核会为进程创建一系列数据结构,其中最重要的就是上章学习的task_struct结构,它就是进程描述符,表明进程在生命周期内的所有特征。

  1. 内核为进程创建两个栈,一个是用户栈,一个是内核栈,分别处于用户态和内核态使用的栈。
  2. 当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;当进程在内核空间时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。
  3. 内核栈是内存中属于操作系统空间的一块区域,其主要用途为:
    1. 保存中断现场,对于嵌套中断,被中断程序的现场信息依次压入系统栈,中断返回时逆序弹出;
    2. 保存操作系统子程序间相互调用的参数、返回值、返回点以及子程序(函数)的局部变量。
  4. 用户栈是用户进程空间中的一块区域,用于保存用户进程的子程序间相互调用的参数、返回值、返回点以及子程序(函数)的局部变量。

QA:那么为什么不直接用一个栈,何必浪费那么多的空间呢?

  1. 1)如果只用系统栈。系统栈一般大小有限,如果中断有16个优先级,那么系统栈一般大小为15(只需保存15个低优先级的中断,另一个高优先级中断处理程序处于运行)。
  2.     但用户程序子程序调用次数可能很多,那样15次子程序调用以后的子程序调用的参数、返回值、返回点以及子程序(函数)的局部变量就不能被保存,用户程序也就无法正常运行了。
  3. 2)如果只用用户栈。我们知道系统程序需要在某种保护下运行,而用户栈在用户空间(即cpu处于用户态,而cpu处于内核态时是受保护的),不能提供相应的保护措施(或相当困难)。

用户栈

用户栈就是应用程序直接使用的栈,位于应用程序的用户进程空间的最顶端,用来存储局部、临时变量、函数调用。

以函数调用传递调用参数为例,如果选择使用CPU通用寄存器存放参数,但通用寄存器的数目是有限的,当出现函数嵌套调用时,子函数再次使用通用寄存器必然会导致冲突,因此用进程用户栈来传递参数,在调用子函数前,保存原有寄存器的值,子函数退出时再恢复寄存器的值。对于函数的返回,只需要在调用子函数前,将返回地址压入栈中,待子函数调用结束后,将函数返回地址弹出给PC指针,完成函数调用的返回。

如下是函数调用时,进程用户栈的使用情况,当运行中的程序调用另一个函数时,就要进入一个新的栈帧(栈帧是指为一个函数调用单独分配的那部分栈空间,边界由EBP和栈指针ESP界定,EBP 指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动),原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧,被调用的函数运行结束后当前帧全部收缩,回到调用者的帧:

内核在创建进程的时候,在创建task_struct的同时,会为进程创建进程用户栈,用户栈基于进程的虚拟地址空间的管理机制实现,以VMA(按照不同的访问属性和功能划分为不同的内存区域,称之为虚拟内存区域VMA)形式实现。每个进程都有自己的进程地址空间,即在内核中每个进程struct task_struct使用struct mm_struct描述整体的虚拟进程地址空间:

struct mm_struct
{
     struct vm_area_struct *mmap;    //指向虚拟区间(VMA)链表
     struct rb_root mm_rb;           //指向red_black树
     struct vm_area_struct *mmap_cache;    //找到最近的虚拟区间

     unsigned long(*get_unmapped_area)(struct file *filp,unsigned long addr,unsigned long len,unsigned long pgoof,unsigned long flags);

     void (*unmap_area)(struct mm_struct *mm,unsigned long addr);

     unsigned long mmap_base;

     unsigned long task_size;   //拥有该结构体的进程的虚拟地址空间的大小
     unsigned long cached_hole_size;
     unsigned long free_area_cache;

     pgd_t *pgd;  //指向页全局目录

     atomic_t mm_users;         //用户空间中有多少用户
     atomic_t mm_count;         //对"struct mm_struct"有多少引用

     int map_count;            //虚拟区间的个数
     struct rw_semaphore mmap_sem;
     spinlock_t page_table_lock;       //保护任务页表和mm->rss

     struct list_head mmlist;          //所有活动mm的链表
     mm_counter_t _file_rss;
     mm_counter_t _anon_rss;
     unsigned long hiwter_rss;
     unsigned long hiwater_vm;


     unsigned long total_vm,locked_vm,shared_vm,exec_vm;
     usingned long stack_vm,reserved_vm,def_flags,nr_ptes;

     unsingned long start_code,end_code,start_data,end_data;  //代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data

     unsigned long start_brk,brk,start_stack;    //start_brk和brk记录有关堆的信息,start_brk是用户虚拟地址空间初始化,brk是当前堆的结束地址,start_stack是栈的起始地址

     unsigned long arg_start,arg_end,env_start,env_end;     //参数段的开始arg_start,结束arg_end,环境段的开始env_start,结束env_end
     unsigned long saved_auxv[AT_VECTOR_SIZE];

     struct linux_binfmt *binfmt;

     cpumask_t cpu_vm_mask;
     mm_counter_t context;
     unsigned int faultstamp;
     unsigned int token_priority;
     unsigned int last_interval;

     unsigned long flags;
     struct core_state *core_state;
}

针对虚拟进程地址空间中不同的属性区域由一个个的VMA通过一定方式组织在一起,每个VMA也就是struct vm_area_struct进行表示,比如描述栈这个内存区域也是由一个VMA进行描述其开始地址、结束地址、属性权限等等。

struct vm_area_struct {
 /* The first cache line has the info for VMA tree walking. 
 第一个缓存行具有VMA树移动的信息*/
 
 unsigned long vm_start;  /* Our start address within vm_mm. */
 unsigned long vm_end;  /* The first byte after our end address within vm_mm. */
 
 /* linked list of VM areas per task, sorted by address
 每个任务的VM区域的链接列表,按地址排序*/
 struct vm_area_struct *vm_next, *vm_prev;
 
 struct rb_node vm_rb;
 
 /*
  此VMA左侧最大的可用内存间隙(以字节为单位)。 
  在此VMA和vma-> vm_prev之间,
  或者在VMA rbtree中我们下面的一个VMA与其->vm_prev之间。 
  这有助于get_unmapped_area找到合适大小的空闲区域。
  */
 unsigned long rb_subtree_gap;
 
 /* Second cache line starts here. 
 第二个缓存行从这里开始*/
 
 struct mm_struct *vm_mm; /* 我们所属的address space*/
 pgprot_t vm_page_prot;  /* 此VMA的访问权限 */
 unsigned long vm_flags;  /* Flags, see mm.h. */
 
 /*
  对于具有地址空间(address apace)和后备存储(backing store)的区域,
  链接到address_space->i_mmap间隔树,或者链接到address_space-> i_mmap_nonlinear列表中的vma。
  */
 union {
  struct {
   struct rb_node rb;
   unsigned long rb_subtree_last;
  } linear;
  struct list_head nonlinear;
 } shared;
 
 /*
  在其中一个文件页面的COW之后,文件的MAP_PRIVATE vma可以在i_mmap树和anon_vma列表中。
  MAP_SHARED vma只能位于i_mmap树中。 
  匿名MAP_PRIVATE,堆栈或brk vma(带有NULL文件)只能位于anon_vma列表中。
  */
 struct list_head anon_vma_chain; /* Serialized by mmap_sem & * page_table_lock
          由mmap_sem和* page_table_lock序列化*/
 struct anon_vma *anon_vma; /* Serialized by page_table_lock 由page_table_lock序列化*/
 
 /* 用于处理此结构体的函数指针 */
 const struct vm_operations_struct *vm_ops;
 
 /* 后备存储(backing store)的信息: */
 unsigned long vm_pgoff;  /* 以PAGE_SIZE为单位的偏移量(在vm_file中),*不是* PAGE_CACHE_SIZE*/
 struct file * vm_file;  /* 我们映射到文件(可以为NULL)*/
 void * vm_private_data;  /* 是vm_pte(共享内存) */
 
#ifndef CONFIG_MMU
 struct vm_region *vm_region; /* NOMMU映射区域 */
#endif
#ifdef CONFIG_NUMA
 struct mempolicy *vm_policy; /* 针对VMA的NUMA政策 */
#endif
};

进程虚拟地址空间中,栈区,也就是进程栈的初始大小是由编译器和链接器计算出来的,Linux内核会随着栈情况动态增长(也就是添加新的页表),但是栈区不是无限制地增长,其有最大限制,如通过ulimit -s命令查看:

8192kB,即8M大小

关于进程栈的动态增长:进程在运行的过程中,不断地向栈区压入数据,当超出初始大小容量时,触发缺页异常page_fault,Linux内核调用:

expand_stack()-> expand_upwards() ->acct_stack_growth()检查是否还有合适的地方用于栈的增长:

static int acct_stack_growth(struct vm_area_struct *vma,
        unsigned long size, unsigned long grow)
{
 struct mm_struct *mm = vma->vm_mm;
 unsigned long new_start;

 /* address space limit tests */
 if (!may_expand_vm(mm, vma->vm_flags, grow))
  return -ENOMEM;

 /* Stack limit test */
 if (size > rlimit(RLIMIT_STACK))
  return -ENOMEM;

 /* mlock limit tests */
 if (vma->vm_flags & VM_LOCKED) {
  unsigned long locked;
  unsigned long limit;
  locked = mm->locked_vm + grow;
  limit = rlimit(RLIMIT_MEMLOCK);
  limit >>= PAGE_SHIFT;
  if (locked > limit && !capable(CAP_IPC_LOCK))
   return -ENOMEM;
 }

 /* Check to ensure the stack will not grow into a hugetlb-only region */
 new_start = (vma->vm_flags & VM_GROWSUP) ? vma->vm_start :
   vma->vm_end - size;
 if (is_hugepage_only_range(vma->vm_mm, new_start, size))
  return -EFAULT;

 /*
  * Overcommit..  This must be the final test, as it will
  * update security statistics.
  */
 if (security_vm_enough_memory_mm(mm, grow))
  return -ENOMEM;

 return 0;
}

内核栈

内核在创建进程的时候,在创建task_struct的同时,会为进程创建两个栈,第一个栈也就是上面分析到的进程用户栈,存在于用户空间使用,另外还有一个内核栈,存放在内核空间。

内核栈存在的意义:如系统调用在陷入内核后,系统调用中也是存在函数调用和自动变量,这些都需要栈支持。

每个进程都要有独自的内核栈的必要性:所有进程在运行时,都有可能通过系统调用陷入内核态继续执行,假设第一个进程陷入内核执行的时候,需要等待某个资源,主动schedule(),让出CPU,第二个进程假设也通过系统调用进入了内核态,如果进程共享内核栈,那么第二个进程在系统调用压栈时会破坏第一个进程栈数据。

关于内核栈如下:

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;

 /*
  * This begins the randomizable portion of task_struct. Only
  * scheduling-critical items should be added above here.
  */
 randomized_struct_fields_start

 void    *stack;
  ......
  }

进程task_struct中的stack指向该进程的内核栈

进程的stack创建过程如下:

_do_fork()->copy_process()->dup_task_struct()

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
 struct task_struct *tsk;
 unsigned long *stack;
 struct vm_struct *stack_vm_area;
 int err;

 if (node == NUMA_NO_NODE)
  node = tsk_fork_get_node(orig);
 tsk = alloc_task_struct_node(node);
 if (!tsk)
  return NULL;

 stack = alloc_thread_stack_node(tsk, node);
 if (!stack)
  goto free_tsk;

 stack_vm_area = task_stack_vm_area(tsk);

 err = arch_dup_task_struct(tsk, orig);

 /*
  * arch_dup_task_struct() clobbers the stack-related fields.  Make
  * sure they're properly initialized before using any stack-related
  * functions again.
  */
 tsk->stack = stack;
  ......
  }

stack通过alloc_thread_stack_node(tsk,node)创建内核栈,内核栈的大小为THREAD_SIZE,如下在x86 64位系统上定义,为16K

#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)
#define CURRENT_MASK (~(THREAD_SIZE - 1))

如下测试栈大小代码:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched.h>
static int __init test_stack(void)
{
    union thread_union *test;
    int size;
    test = current->stack;
    size = sizeof(*test);
    pr_info("%d\n",size);
    return 0;

}

static void __exit test_exit(void)
{

    pr_info("end\n");
}

module_init(test_stack);
module_exit(test_exit);
MODULE_LICENSE("GPL"); 

内核中有一个union thread_union具体来表示一个内核栈,内核栈指向该共用体

union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
 struct thread_info thread_info;
#endif
 unsigned long stack[THREAD_SIZE/sizeof(long)];
};

查看CONFIG_THREAD_INFO_IN_TASK是否开启(x86-64位-kernel 4.15

CONFIG_THREAD_INFO_IN_TASK=y, 说明thread_info在task_struct中,而不在thread_union。此时与体系相关的thread_info结构体作为task_struct的第一个成员, 而thread_union共用体中只有栈。

所以内核栈可以描绘以下所示:

其中struct pt_regs是跟体系结构相关,存放寄存器的数据,Linux内核就是使用它来格式化内核栈的内容,如在x86_64架构下:

struct pt_regs {
/*
 * C ABI says these regs are callee-preserved. They aren't saved on kernel entry
 * unless syscall needs a complete, fully filled "struct pt_regs".
 */
 unsigned long r15;
 unsigned long r14;
 unsigned long r13;
 unsigned long r12;
 unsigned long bp;
 unsigned long bx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
 unsigned long r11;
 unsigned long r10;
 unsigned long r9;
 unsigned long r8;
 unsigned long ax;
 unsigned long cx;
 unsigned long dx;
 unsigned long si;
 unsigned long di;
/*
 * On syscall entry, this is syscall#. On CPU exception, this is error code.
 * On hw interrupt, it's IRQ number:
 */
 unsigned long orig_ax;
/* Return frame for iretq */
 unsigned long ip;
 unsigned long cs;
 unsigned long flags;
 unsigned long sp;
 unsigned long ss;
/* top of stack page */
};

压栈过程中,和上面的数据结构struct pt_regs成员一一对应(顺序固定且是倒序)

关于dump_stack():Linux内核中提供了一个可以打印出内核调用堆栈的函数 dump_stack(),该函数在我们调试内核的过程中可以打印出函数调用关系,以及让我们了解内核的调用关系。对Linux故障定位非常有帮助,在希望打印栈信息的函数中调用dump_stack()即可。该函数的主要原理是向上回溯函数栈,然后根据取得的函数地址,区内核符合表中查询对应的函数名,并最终打印。

测试Demo如下:内核模块入口函数调用aaa->bbb->ccc,并在ccc中打印内核栈中存在的函数调用关系

#include <linux/module.h>   
#include <linux/kernel.h>
#include <linux/init.h>
#include <asm/ptrace.h>
 
void aaa(int a);
void bbb(int b);
void ccc(int c);
 
void aaa(int a)
{
 int b = a + 10;
 bbb(b);
}
 
void bbb(int b)
{
 int c = b + 10;
 ccc(c);
}
 
void ccc(int c)
{
 dump_stack();
 printk("c is %d\n",c);
}
 
static int __init my_init( void )
{
 int a = 10;
 aaa(a);
 printk("my_init \n");  
  return 0;
}
 
static void __exit my_exit(void )
{
     printk("my_exit \n"); 
}
 
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL"); 

参考文献:
https://blog.csdn.net/u014426028/article/details/104446201
https://cloud.tencent.com/developer/article/1805657
https://blog.csdn.net/SweeNeil/article/details/88061381
https://mp.weixin.qq.com/s/6REkh-H_gJ3W-Br0muGPOw (版权归原作者所有,侵删)

往期精彩回顾:

1. 内核态内核栈

在每个进程的生命周期内,经常会通过系统调用(SYSCALL)或者中断进入内核。在执行系统调用后,这些内核代码所使用的栈并不是原先用户空间的栈,而是一个内核空间的栈,这个栈被称作进程的“内核栈”。

由用户态切换到内核态,内核将用户态时的堆栈寄存器的值保存在内核栈中,以便从内核栈切换回进程栈时能找到用户栈的地址。但是,从进程栈切换到内核栈时,内核是如何找到该进程的内核栈的地址信息,这部分放到后续章节中详细介绍。

对于task_struct定义在include/linux/sched.h中,有和内核栈相关的数据项

  struct task_struct {
      struct thread_info thread_info;
      ...
      void * stack;
      ...
  }

其中,thread_info是一个体系相关的描述符,不同的硬件体系所需要记录的标志是不同,因此内核将和特定的硬件体系相关的标志定义在此结构中。

每个task的栈分成用户栈和内核栈两部分,进程内核栈在kernel中的定义在include/linux/sched.h中,如下:

union thread_unoin {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
}

每个task的内核栈大小THREAD_SIZE :

//ARM架构 , 8K
#define THREAD_SIZE_ORDER    1
#define THREAD_SIZE        (PAGE_SIZE << THREAD_SIZE_ORDER)
#define THREAD_START_SP        (THREAD_SIZE - 8)

//ARM64架构, 16K
#define THREAD_SIZE        16384
#define THREAD_START_SP        (THREAD_SIZE - 16)

//X86_64, 16K
#define THREAD_SIZE_ORDER    (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

Linux 给每个 task 都分配了内核栈。在 32 位系统上 arch/x86/include/asm/page_32_types.h,是这样定义的:一个 PAGE_SIZE 是 4K,左移一位就是乘以 2,也就是 8K。但是内核栈在 64 位系统上arch/x86/include/asm/page_64_types.h,是这样定义的:在 PAGE_SIZE 的基础上左移两位,也即 16K,并且要求起始地址必须是 8192 的整数倍。

2. 通过 task_struct 找内核栈

进程在内核中相关的主要数据结构有进程描述符task_struct、thread_info和mm_struct。上面的共同体thread_union 里,就有thread_info。我们都熟悉进程描述符task_struct,那么thread_info有什么用?

如果有一个task_struct的stack指针在手,你可以通过下面的函数找到这个线程的内核栈:

//sched.h (include\linux    105464    2018/3/18    592)
static inline void *task_stack_page(const struct task_struct *task)
{
    return task->stack;
}

从 task_struct 如何得到相应的 pt_regs 呢?我们可以通过下面的函数:

//processor.h    (arch\x86\include\asm)
#define task_pt_regs(task) \
({                                    \
    unsigned long __ptr = (unsigned long)task_stack_page(task);    \
    __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING;        \
    ((struct pt_regs *)__ptr) - 1;                    \
})

你会发现,这是先从 task_struct 找到内核栈的开始位置。然后这个位置加上 THREAD_SIZE 就到了最后的位置,然后转换为 struct pt_regs,再减一,就相当于减少了一个 pt_regs 的位置,就到了这个结构的首地址。

对于arm64也同样使用

#define task_pt_regs(p) \
    ((struct pt_regs *)(THREAD_START_SP + task_stack_page(p)) - 1)

所以我们可以通过task_struct,就能够轻松得到内核栈和内核寄存器,如下图所示

在这里插入图片描述

3. 通过内核栈找 task_struct

那如果一个当前在某个 CPU 上执行的进程,你同样也可以知道 task_struct 在哪里,这个艰巨的任务要交给thread_info这个结构。

3.1. ARM架构:

查看arm架构的源码发现,前面提到的CONFIG_THREAD_INFO_IN_TASK宏是关闭的,且没有提供对外kconfig接口。也就是说在32位 arm架构中,thread_info 结构肯定在进程内核栈中。下面这种current宏适用于所有合“thread_info 结构在内核栈中”的架构:

struct thread_info {
    unsigned long        flags;        /* low level flags */
    int            preempt_count;    /* 0 => preemptable, <0 => bug */
    mm_segment_t        addr_limit;    /* address limit */
    struct task_struct    *task;        /* main task structure */
    __u32            cpu;        /* cpu */
    __u32            cpu_domain;    /* cpu domain */
    struct cpu_context_save    cpu_context;    /* cpu context */
    __u32            syscall;    /* syscall number */
    __u8            used_cp[16];    /* thread used copro */
    unsigned long        tp_value[2];    /* TLS registers */
#ifdef CONFIG_CRUNCH
    struct crunch_state    crunchstate;
#endif
    union fp_state        fpstate __attribute__((aligned(8)));
    union vfp_state        vfpstate;
#ifdef CONFIG_ARM_THUMBEE
    unsigned long        thumbee_state;    /* ThumbEE Handler Base register */
#endif
};

这里面有个成员变量 task 指向 task_struct,所以我们常用 current_thread_info()->task 来获取 task_struct。

#define get_current() (current_thread_info()->task)
static inline struct thread_info *current_thread_info(void)
{
    return (struct thread_info *)
        (current_stack_pointer & ~(THREAD_SIZE - 1));
}

而 thread_info 的位置就是内核栈的最高位置,减去 THREAD_SIZE,就到了 thread_info 的起始地址。

3.2. ARM64架构:

通过发现在ARM64架构中,其定义如下:

#define get_current() (current_thread_info()->task)
static inline struct thread_info *current_thread_info(void)
{
    unsigned long sp_el0;

    asm ("mrs %0, sp_el0" : "=r" (sp_el0));

    return (struct thread_info *)sp_el0;
}

ARM64使用sp_el0,在进程切换时暂存进程描述符地址,sp就是堆栈寄存器。在ARM64里,CPU运行在四个级别(或者叫运行空间),分别是el0、el1、el2、el3,el0则就是用户空间,el1则是内核空间。

3.3. X64架构(64位架构)

在x86上也可以采用和32位ARM类似的获取方式,然而在64位体系结构中,linux kernel一直采用的是另一种方式:使用了current_task这个每CPU变量,来存储当前正在使用cpu的进程描述符struct task_struct。

struct task_struct;

DECLARE_PER_CPU(struct task_struct *, current_task); 
static __always_inline struct task_struct *get_current(void)
{
    return this_cpu_read_stable(current_task);
}
 
#define current get_current

到这里,你会发现,新的机制里面,每个 CPU 运行的 task_struct 不通过 thread_info 获取了,而是直接放在 Per CPU 变量里面了。

4. 总结

实际上在linux kernel中,task_struct、thread_info都用来保存进程相关信息,即进程PCB信息。然而不同的体系结构里,进程需要存储的信息不尽相同,linux使用task_struct存储通用的信息,将体系结构相关的部分存储在thread_info中。

在内核态,32 位和 64 位都使用内核栈,格式也稍有不同,主要集中在 pt_regs 结构上
在内核态,32 位和 64 位的内核栈和 task_struct 的关联关系不同。
x86中32 位主要靠 thread_info,64 位主要靠 Per-CPU 变量,而ARM平台不论是32位还是64位,都是使用thread_info,其原理基本类似。

在这里插入图片描述

5. 用户栈

用户栈是应用程序直接使用的栈,位于应用程序用户进程空间的最顶端,用来存储函数调用过程中的局部变量、零食变量等,用户支持用户进程的函数调用

背景
在广袤的代码中堆栈无疑是一个高热度的技术用语, 就linux而言你能常观察到的几个场景有:

用户态堆栈
函数func_foo中用堆栈来保存寄存器、局部变量等等:

图 1 用户态堆栈实例

内核堆栈
在内核中也需要使用堆栈,典型的场景就是异常处理中使用堆栈保存异常现场:

图2 内核堆栈实例

有一个细思极恐的事情,在同一个cpu上这些"堆栈"都是用同一个符号"sp"来指示。

用户态正在使用"sp"保存局部变量, 时钟中断来了,linux进入异常处理流程, 然后又用"sp"来保存现场;
 进程prev正在以120码的速度欢畅的使用"sp"来调用函数运行, 然后切换到了进程next; 进程next也要用"sp"来进行自己的函数调用。
问题来了,都在用"sp", 这是万物共享时代的终极产物? 还是"sp"会分身术?

这一切都是通过上下文切换来完成的,而”sp”就是上下文中的一个小部分。

一、ARMv8堆栈指针简介
  堆栈的切换流程和硬件息息相关,我们这里以armv8为背景来进行讲述。Armv8中对异常运行级别进行了划分,不同的运行级别使用的sp可能会有所不同。

  当程序运行在EL0时使用的是SP_EL0;其他Exception level下,可以使用SP_EL0或者当前Exception level所对应的SP_ELn寄存器;具体使用SP_EL0还是SP_EL1是由PSTATE.SP决定,对应的寄存器是Spsel。若Spsel==0,那么强制使用SP_EL0,否则使用用SP_ELn。在linux中Spsel默认位1。因而异常发生时,默认会切换到SP_ELn。

二、用户态与内核态的堆栈切换
  实际上在上第一章已经可以从硬件意义上解释问题1了。就armv8的Linux而言,用户态程序(EL0异常级别)发生异常、进入到内核态(EL1异常级别) sp会从SP_EL0切换到SP_EL1。

  下面我们就结合一个例子看看Linux是如何基于cpu架构特点从软件上来完成sp的切换的。

  任务P在用户态运行时堆栈指针sp实际指向的是SP_EL0寄存器,而SP_EL0存放的就是任务P的用户态堆栈虚拟地址,其值在P的/proc/$pid/maps中的[stack]这个vma区间中。此时任务P发生了一次异常,这会引发如下的一系列连锁反应:

 异常发生 堆栈寄存器切换
          由于中断、系统调用等引发一次系统异常,运行级别从EL0切换到EL1,sp也由硬件自动从SP_EL0切换到SP_EL1,此时SP_EL1指向内核地址空间。在此之前SP_EL1中已经存放了任务P的内核态堆栈的地址,即task_struct->stack中的某个位置(注1)。

保存用户态堆栈指针SP_EL0 等异常现场
    进入异常处理流程初期,由kernel_entry宏将SP_EL0寄存器内容保存到SP_EL1指向的内核堆栈中,然后将SP_EL0挪作它用,比如current宏的实现。

恢复用户态堆栈指针 等异常现场
    在异常处理流程后期,由kernel_exit宏将之前存放到SP_EL1内核堆栈中存放的SP_EL0的值恢复到SP_EL0中;

异常返回 堆栈寄存器切换
    在异常处理处理流程的终点会执行"eret"返回到用户态,PE运行级别从EL1恢复到EL0,sp也随之从SP_EL1切换到SP_EL0。

  一图胜千言,整个流程如下所示:

图3 内核态用户态sp上下文切换

三、进程之间的堆栈切换
  了解了用户态/内核态之间的堆栈指针切换后,我们再来看看进程与进程之间的sp是如何切换的。这个过程稍显复杂,我们从简单到细致一步一步区分析。

  任务之间的切换细节对于我们分析进程之间堆栈切换有着承上启下的作用。对于缺少想象空间的我来说,举例子永远是我最喜欢的方式。下面我就例举进程prev切换到进程next的详细情况,顺便把堆栈切换的流程夹杂其中。

  要注意的是,进程之间切换一定是要在内核中发生,因而需要有异常发生。

发生异常 堆栈寄存器切换 保存异常现场
   进程prev运行过程中发生一次系统异常(系统调用、中断等等),异常级别由EL0变为EL1,sp也随之从SP_EL0切换到SP_EL1, 然后进入异常处理流程入口由kernel_entry宏将SP_EL0寄存器内容存放到SP_EL1所对应的内核堆栈中;

发生调度
   在系统异常处理流程中发生一次调度(如prev系统调用阻塞、或者被更高优先级任务抢占),进入__schedule()调度函数。

调度产生切换
   调度函数__schedule() 首先选择下一个将要运行的任务next; 然后,经过一系列准备之后调用cpu_switch_to(prev, next)函数从任务prev切换到任务next运行。

ENTRY(cpu_switch_to)  /* 两个参数:x0=prev, x1=next*/
        /* 取prev任务的cpu_context到寄存器x8; */
        mov     x10, #THREAD_CPU_CONTEXT 
        add     x8, x0, x10
 
      /* 保存prev任务的现场”x19~29, sp, lr”到prev的cpu_context */    
        mov     x9, sp
        stp     x19, x20, [x8], #16             // store callee-saved registers
        stp     x21, x22, [x8], #16
        stp     x23, x24, [x8], #16
        stp     x25, x26, [x8], #16
        stp     x27, x28, [x8], #16
        stp     x29, x9, [x8], #16
        str     lr, [x8]
 
        /* 取next任务的cpu_context到寄存器x8 */
        add     x8, x1, x10
        ldp     x19, x20, [x8], #16             // restore callee-saved registers
        ldp     x21, x22, [x8], #16
        ldp     x23, x24, [x8], #16
        ldp     x25, x26, [x8], #16
        ldp     x27, x28, [x8], #16
        ldp     x29, x9, [x8], #16
        ldr     lr, [x8]       /* 将next任务原来现场的lr,sp加载到当前现场*/
        mov     sp,  x9
        /* x9保存的是next任务的内核堆栈 */
        and     x9, x9, #~(THREAD_SIZE - 1)
        msr     sp_el0, x9       /* 确保current宏取到的是next任务 */
        ret          /* ret指令*/
ENDPROC(cpu_switch_to)
   这个函数的逻辑还是很清晰:

     [1] THREAD_CPU_CONTEXT是一个任务cpu_context相对于task_struct的偏移, 即THREAD_CPU_CONTEXT + &task_struct就是task_struct.thread.cpu_context的地址;

     [2] 将prev的现场信息(x19~29, sp, lr寄存器)保存到自己的task_struct.thread.cpu_context结构中,在下次自己切换回来时恢复取用;

     [3] 然后再从next的cpu_context结构中取出next的现场信息到当前cpu的x19~x29, sp, lr寄存器中, 这样堆栈sp, 链接寄存器lr等等寄存器都切换到了next任务;

     [4]更新sp_el0,最后执行ret指令。

 这里有两条指令需要注意:

mov sp, x9
这条指令直接将x9寄存器的内容填充到sp;由于此时系统处于EL1异常级别,因而sp指向的是SP_EL1,因而这里实际上是将原来prev的内核堆栈切换到了next任务内核堆栈的某个位置(注2);

ret
 在aarch64架构中使用bl和ret指令来实现函数的调用与返回。bl指令先将下一条指令放到lr寄存器,然后跳转到目标地址执行;ret指令执行时cpu跳转到lr寄存器中所指向的地址执行。由于arch_switch_to()的后面部分从next任务的cpu_context结构中取出了现场信息填入了lr寄存器,因而这里的ret指令会跳转到next任务lr指针地址执行(注3), ret执行完成后当前cpu上的上下文实际上已经更朝换代,火车前进的轨道由prev切换到了next,从此一去.....可能还会返。

新任务运行
   现在pc是沿着next的轨道在前进。那next的这个轨道是通向哪里呢?最终最终,就是走到异常返回的流程,即下面两个流程;

恢复新任务异常现场
   执行kernel_exit,从当前内核堆栈(已经切换至next任务的内核堆栈)中取出next的用户态堆栈恢复到SP_EL0寄存器;

返回新任务用户态
   在内核态的最后阶段指向"eret"指令返回到next用户态上下文,PE运行级别从EL1恢复到EL0,sp也由自动切换到SP_EL0,这时的SP_EL0已经是next任务的用户态堆栈(注4)了。

  总结一下任务之间的堆栈指针是如何切换的:

 图4 进程之间堆栈切换情况

四、细节
 第二章和第三章已经按部就班的讲述了用户态/内核态、任务与任务之间的堆栈指针sp如何切换的。但是仍然有一些内容我们一笔带过并未认真去揣摩细节,特别是前面各个章节中的注1..注4等等注意的地方。下面我们就对这些“注”细节进行讲解。

4.1 新任务调度的情况
 新任务的特点就是创建好后从来没有运行过。这种情况一般是fork()系统调用创建好next,next挂入到就绪队列中,由prev任务进入到内核态调用__schedule()函数选择next任务运行。

 也就是说next是第一次被调度运行,所以它的用户态堆栈、它的内核栈都是全新的。

 那它的用户态堆栈、内核栈的内容是什么呢?要回答这个问题,我们需要用一个章节的时间来了解一下。

Fork新任务堆栈相关初始化流程
   当next任务通过fork()调用创建时,会执行如下两个与堆栈相关的关键流程:

图5 fork新任务堆栈初始化相关流程

  【1】在copy_process初期调用alloc_thread_stack_node()函数为新任务next分配大小为16Kb(其他架构可能大小会有差异)的内核堆栈,并赋给新任务task_struct->stack;

  【2】对于普通的fork()系统调用(为了简化讨论vfork与clone的情况暂时不考虑)copy_mm函数会将父进程的各个vma以及页表都拷贝到新任务next,也就是说新任务next与父进程的地址空间是一样的;其中[stack]也是从父进程拷贝过来的一组vma;

  【3】调用copy_thread函数初始化任务的内核堆栈和上下文结构。

int copy_thread(unsigned long clone_flags, unsigned long stack_start,
                 unsigned long stk_sz, struct task_struct *p)
{
    struct pt_regs *childregs = task_pt_regs(p);   //指向新任务内核栈顶pt_regs
    memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));
        。。。。。。。
    if (likely(!(p->flags & PF_KTHREAD))) { //我们只考虑用户态任务的情况
        *childregs = *current_pt_regs();   //拷贝父进程的内核栈内容  
        childregs->regs[0] = 0;   //子任务fork()返回值为0
        。。。。。。。
    } else {
        。。。。。。。
    }
    //设置cpu_context.pc和cpu_context.sp, 在arch_switch_to会用到
    p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
    p->thread.cpu_context.sp = (unsigned long)childregs;
    ptrace_hw_copy_thread(p);
    return 0;
}

 上面的函数,注意是准备p->thread.cpu_context.pc 和 p->thread.cpu_context.sp。

 其中,p->thread.cpu_context.pc是pc指针,指向ret_from_fork函数;p->thread.cpu_context.sp是内核堆栈指针,它所指向的位置和内容由如下流程确定。

 首先,通过task_pt_regs(p)提取新任务p内核堆栈中存放struct pt_regs的起始位置(实际为(p->stack + 16kb - 16) - sizeof(struct pt_regs)的位置);

 其次,复制父进程堆栈中pt_regs中的内容到新进程堆栈的pt_regs中,但是pt_regs->regs[0]除外,因为这个是fork()系统调用的返回值,而新任务返回值为0。

*childregs = *current_pt_regs()
childregs->regs[0] = 0;
 上面的情况如下图所示:

图6 初始化后子任务的内核堆栈情况

fork新任务总结一下
  对于一个新创建的任务,它的内核堆栈指针就是上图中p->thread.cpu_context.sp,这就是前面(注2)在切换到新任务的情况;同时,parent任务内核堆栈中存放的SP_EL0值也复制到新任务的的内核堆栈中来了,这样在新任务从fork()系统调用内核态返回到用户态、恢复用户态堆栈指针时,用户态堆栈指针SP_EL0实际上是等于parent的用户态堆栈指针的,这就是(注4)在切换到新任务的情况,同时fork()返回值为0。这样也解释了fork()系统调用的某些现象(这里就不详细展开)。

 我们再说说前面的(注3),上面除了了堆栈相关的操作外,还设置了新任务的p->thread.cpu_context.pc。让我们再次把视线拉回到arch_switch_to()函数。

        ldp     x29, x9, [x8], #16
        ldr     lr, [x8]       /* 新任务cpu_context.cpu加载到lr */
       mov     sp,  x9    /* 切换到新任务的内核堆栈 */
 其中x8就是新任务的p->thread.cpu_context结构,上面的两条指令就是将堆栈指针切换到next的内核堆栈;然后将cpu_context.cpu放到lr,此时的cpu_context.cpu已经初始化为ret_from_fork函数,因而arch_switch_to()函数的”ret”指令就跳转到ret_from_fork()函数了,ret_from_fork()具体细节就不展开了,但是最终还是要进行到kernel_exit、eret返回到用户态。

4.2 已有调度史任务的情况
 场景2会简单一些,大部分内容在其他章节已经由讲过,说一下next任务堆栈指针的情况:

图7 进程切换过程中内核堆栈的变化

  [1] 在异常的情况下从用户态进入内核态,此时SP从SP_EL0切换到SP_EL1,此时SP_EL1指向内核堆栈pt_regs的起始位置,这就是(注1)中的某个位置;

  [2] 在异常处理入口SP_EL1向下增长保留出一个struct pt_regs的空间以保存SP_EL0等异常现场寄存器;

  [3] 在异常处理中发生调度,调用arch_switch_to函数,SP_EL1也因为函数调用等原因向下增长。在arch_switch_to函数中会将当前SP_EL1的值保存到next任务的cpu_context结构中,然后cpu调度到其他任务执行;

  [4] 当next任务再次被调度到运行时,内核会从next任务的cpu_context中取出保存的SP_EL1和pc,继续next之前未运行完毕的arch_switch_to()以及更上层的函数;

  [5] 执行kernel_exit,next任务的SP_EL1最终恢复到pt_regs起始位置,SP0_EL1也从堆栈中恢复。

总结:
  堆栈指针sp在linux中的切换是随着异常级别在SP_EL0和SP_EL1之间变化; SP_EL0和SP_EL1的变化则是在Linux软件中通过各种场景下的现场保存、恢复、初始化等等来决定的。 

  这部分内容本身牵涉的比较广比较多,因而讲的逻辑也不是很顺,抛砖引玉,希望能够对各位读者有所帮助 心已足矣。
————————————————
版权声明:本文为CSDN博主「温暖的电波」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/wennuanddianbo/article/details/118070324
————————————————
版权声明:本文为CSDN博主「生活需要深度」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u012294613/article/details/128093414

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值