“用户栈和内核栈”

进程是程序的一次执行过程。用剧本和演出来类比,程序相当于剧本,而进程则相当于剧本的一次演出,舞台、灯光则相当于进程的运行环境。

进程的堆栈

每个进程都有自己的堆栈,内核在创建一个新的进程时,在创建进程控制块 task_struct 的同时,也为进程创建自己堆栈。一个进程有2个堆栈,用户堆栈和内核堆栈;用户堆栈的空间指向用户地址空间,内核堆栈的空间指向内核地址空间。当进程在用户态运行时,CPU 堆栈指针寄存器指向用户堆栈地址,使用用户堆栈,当进程运行在内核态时,CPU 堆栈指针寄存器指向的是内核栈空间地址,使用的是内核栈;

进程用户栈和内核栈之间的切换

当进程由于中断或系统调用从用户态转换到内核态时,进程所使用的栈也要从用户栈切换到内核栈。系统调用实质就是通过指令产生中断,称为软中断。进程因为中断 (软中断或硬件产生中断) ,使得CPU切换到特权工作模式,此时进程陷入内核态,进程进入内核态后,首先把用户态的堆栈地址保存在内核堆栈中,然后设置堆栈指针寄存器的地址为内核栈地址,这样就完成了用户栈向内核栈的切换。

当进程从内核态切换到用户态时,最后把保存在内核栈中的用户栈地址恢复到CPU栈指针寄存器即可,这样就完成了内核栈向用户栈的切换。

这里要理解一下内核堆栈。前面我们讲到,进程从用户态进入内核态时,需要在内核栈中保存用户栈的地址。那么进入内核态时,从哪里获得内核栈的栈指针呢?

要解决这个问题,先要理解从用户态刚切换到内核态以后,进程的内核栈总是空的。这点很好理解,当进程在用户空间运行时,使用的是用户栈;当进程在内核态运行时,内核栈中保存进程在内核态运行的相关信息,但是当进程完成了内核态的运行,重新回到用户态时,此时内核栈中保存的信息全部恢复,也就是说,进程在内核态中的代码执行完成回到用户态时,内核栈是空的。

理解了从用户态刚切换到内核态以后,进程的内核栈总是空的,那刚才这个问题就很好理解了,因为内核栈是空的,那当进程从用户态切换到内核态后,把内核栈的栈顶地址设置给CPU的栈指针寄存器就可以了。

X86 Linux内核栈定义如下 (可能现在的版本有所改变,但不妨碍我们对内核栈的理解) :

在/include/linux/sched.h中定义了如下一个联合结构:

union task_union { struct task_struct task;

   unsigned long stack[2408];

};

从这个结构可以看出,内核栈占8kb的内存区。实际上,进程的task_struct结构所占的内存是由内核动态分配的,更确切地说,内核根本不给task_struct分配内存,而仅仅给内核栈分配8K的内存,并把其中的一部分给task_struct使用。

这样内核栈的起始地址就是union task_union变量的地址+8K 字节的长度。例如: 我们动态分配一个union task_union类型的变量如下:

unsigned char *gtaskkernelstack

gtaskkernelstack = kmalloc(sizeof(union task_union));

那么该进程每次进入内核态时,内核栈的起始地址均为: (unsigned char *) gtaskkernelstack + 8096

进程上下文

进程切换现场称为进程上下文(context),包含了一个进程所具有的全部信息,一般包括: 进程控制块 (Process Control Block,PCB) 、有关程序段和相应的数据集。

进程控制块PCB (任务控制块)

进程控制块是进程在内存中的静态存在方式,Linux内核中用task_struct表示一个进程 (相当于进程的人事档案) 。进程的静态描述必须保证一个进程在获得CPU并重新进入运行态时,能够精确的接着上次运行的位置继续进行,相关的程序段,数据以及CPU现场信息必须保存。处理机 现场信息主要包括处理机内部寄存器和堆栈等基本数据。

进程控制块一般可以分为进程描述信息、进程控制信息,进程相关的资源信息和CPU现场保护机构。

进程的切换

当一个进程的时间片到时,进程需要让出CPU给其他进程运行,内核需要进行进程切换。

Linux 的进程切换是通过调用函数进程切换函数schedule来实现的。进程切换主要分为2个步骤:

  1. 调用switch_mm()函数进行进程页表的切换;

  2. 调用 switch_to() 函数进行 CPU寄存器切换;

__switch_to定义在/arch/arm/kernel目录下的entry-armv.S 文件中,源码如下:


ENTRY(__switch_to)

UNWIND(.fnstart )

UNWIND(.cantunwind )

add ip, r1, #TI_CPU_SAVE

ldr r3, [r2, #TI_TP_VALUE]

stmia   ip!, {r4 - sl, fp, sp, lr} @ Store most regs on stack

ifdef CONFIG_MMU

ldr r6, [r2, #TI_CPU_DOMAIN]

endif

if LINUX_ARM_ARCH >= 6

ifdef CONFIG_CPU_32v6K

clrex

else

strex   r5, r4, [ip]            @ Clear exclusive monitor

endif

endif

if defined(CONFIG_HAS_TLS_REG)

mcr p15, 0, r3, c13, c0, 3      @ set TLS register

elif !defined(CONFIG_TLS_REG_EMUL)

mov r4, #0xffff0fff

str r3, [r4, #-15]          @ TLS val at 0xffff0ff0

endif

ifdef CONFIG_MMU

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
mov r0, r5
ldmia   r4, {r4 - sl, fp, sp, pc}   @ Load all regs saved previously

UNWIND(.fnend )

ENDPROC(__switch_to)


Switch_to的处理流程如下:

  1. 保存本进程的CPU寄存器 (PC、R0 ~ R13) 到本进程的栈中;
  2. 保存SP(本进程的栈基地址)到task->thread.save 中;
  3. 从新进程的task->thread.save恢复SP为新进程的栈基地址;
  4. 从新进程的栈中恢复新进程的CPU相关寄存器值,
  5. 新进程开始运行,完成任务切换。

这里读者可能会问,在进行任务切换的时候,到底是在运行进程1还是运行进程2呢?进程切换的时候,已经进行页表切换,那页表切换之后,切换进程使用的是进程1还是进程2的页表呢?

要回答这个问题,首先我们要明白由谁来完成进程切换?

通过对操作系统的理解,毫无疑问,进程切换是由内核来完成的,也就是说,在进行进程切换时,CPU运行在内核模式,使用的是内核空间的内核代码,它既不属于进程1,也不属于进程2,当进程的时间片到时,内核提供服务来完成进程的切换。既不使用进程1的页表,也不使用进程2的页表,使用的内核映射页表。这样我们就很好理解上面的问题了。

https://blog.csdn.net/wwh578867817/article/details/49076659

进程内核栈

在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈。进程内核栈在进程创建的时候,通过 slab 分配器从 thread_info_cache 缓存池中分配出来,其大小为 THREAD_SIZE,一般来说是一个页大小 4K;

union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)]; }; thread_union 进程内核栈 和 task_struct 进程描述符有着紧密的联系。由于内核经常要访问 task_struct,高效获取当前进程的描述符是一件非常重要的事情。因此内核将进程内核栈的头部一段空间,用于存放 thread_info 结构体,而此结构体中则记录了对应进程的描述符,两者关系如下图 (对应内核函数为 dup_task_struct()):

有了上述关联结构后,内核可以先获取到栈顶指针 esp,然后通过 esp 来获取 thread_info。这里有一个小技巧,直接将 esp 的地址与上 ~(THREAD_SIZE - 1) 后即可直接获得 thread_info 的地址。由于 thread_union 结构体是从 thread_info_cache 的 Slab 缓存池中申请出来的,而 thread_info_cache 在 kmem_cache_create 创建的时候,保证了地址是 THREAD_SIZE 对齐的。因此只需要对栈指针进行 THREAD_SIZE 对齐,即可获得 thread_union 的地址,也就获得了 thread_union 的地址。成功获取到 thread_info 后,直接取出它的 task 成员就成功得到了 task_struct。其实上面这段描述,也就是 current 宏的实现方法:

register unsigned long current_stack_pointer asm (“sp”);

static inline struct thread_info current_thread_info(void)
{ return (struct thread_info
) (current_stack_pointer & ~(THREAD_SIZE - 1)); }

define get_current() (current_thread_info()->task)

define current get_current()

Linux 为什么需要区分这些栈? 为什么需要区分这些栈,其实都是设计上的问题。这里就我看到过的一些观点进行汇总,供大家讨论:

为什么需要单独的进程内核栈?所有进程运行的时候,都可能通过系统调用陷入内核态继续执行。假设第一个进程 A 陷入内核态执行的时候,需要等待读取网卡的数据,主动调用 schedule() 让出 CPU;此时调度器唤醒了另一个进程 B,碰巧进程 B 也需要系统调用进入内核态。那问题就来了,如果内核栈只有一个,那进程 B 进入内核态的时候产生的压栈操作,必然会破坏掉进程 A 已有的内核栈数据;一但进程 A 的内核栈数据被破坏,很可能导致进程 A 的内核态无法正确返回到对应的用户态了; 为什么需要单独的线程栈?Linux 调度程序中并没有区分线程和进程,当调度程序需要唤醒”进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;但是线程和父进程完全共享一份地址空间,如果栈也用同一个那就会遇到以下问题。假如进程的栈指针初始值为 0x7ffc80000000;父进程 A 先执行,调用了一些函数后栈指针 esp 为 0x7ffc8000FF00,此时父进程主动休眠了;接着调度器唤醒子线程 A1: 此时 A1 的栈指针 esp 如果为初始值 0x7ffc80000000,则线程 A1 一但出现函数调用,必然会破坏父进程 A 已入栈的数据。如果此时线程 A1 的栈指针和父进程最后更新的值一致,esp 为 0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针 esp 增加到 0x7ffc8000FFFF,然后线程 A1 休眠;调度器再次换成父进程 A 执行,那这个时候父进程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢?无论栈指针被设置到哪个值,都会有问题不是吗? 进程和线程是否共享一个内核栈?No,线程和进程创建的时候都调用 dup_task_struct 来创建 task 相关结构体,而内核栈也是在此函数中 alloc_thread_info_node 出来的。因此虽然线程和进程共享一个地址空间 mm_struct,但是并不共享一个内核栈。 为什么需要单独中断栈?这个问题其实不对,ARM 架构就没有独立的中断栈。

https://zhuanlan.zhihu.com/p/188577062