您的位置:首页 > 运维架构 > Linux

Linux内核入门(二)——用户态向内核态切换

2013-04-28 22:47 721 查看
那么,程序什么时候使用用户栈,什么时候使用内核栈呢?对,系统调用。也就是执行printf、open、read、write执行C语言库函数时,其最终会用到对应的系统调用,如sys_open、sys_read等。这时候就切换到内核栈。


1 Linux的堆栈切换

我们针对80x86来讨论,其实Linux只在四个地方用了它的堆栈段(由ss+esp指向其栈底地址):

• 系统引导初始化临时实模式下使用的堆栈

• 进入保护模式后提供内核程序始化使用的堆栈,该堆栈也是后来进程0使用的用户态堆栈

• 每个进程通过系统调用,执行内核程序时使用的堆栈,称之为进程的内核态堆栈,每个进程都有自己独立的内核态堆栈

• 进程在用户态执行的堆栈,位于进程逻辑地址空间近末端处

下面简单的介绍一下与普通进程相关的两个堆栈

每个进程都有两个堆栈,分别用于用户态和内核态程序的执行,我们称为用户态堆栈和内核态堆栈。

除了处于不同CPU特权级中,这两个堆栈之间的主要区别还在于任务的内核态堆栈很小,在后面进程管理专题中我们可以看到所保存的数据最多不能超过8096个字节,而进程的用户态堆栈却可以在用户的4GB空间的最底部,并向上延伸。

在用户态运行时,即你看到的那些代码的时候,每个进程(除了进程0和进程1)有自己的4GB地址空间,当一个进程刚被创建时,它的用户态堆栈指针被设置在其地址空间的靠近末端部分,应用程序在用户态下运行时就一直使用这个堆栈,实际物理地址内存则由CPU分页机制确定。

在内核态运行时,每个任务有其自己的内核态堆栈,用于任务在内核代码中执行期间,即执行系统调用以后。其所在的线性地址中位置由该进程TSS段中ss0和esp0 两个字段指定,这两个值来自哪儿呢?

我们的“内存管理”专题中将提到,针对80X86体系,Linux只象征性地使用分段技术,即只是用代码段和数据段。而CPU中的SS寄存器是指向堆栈段的,但是Linux没有使用专门的堆栈段,而是将数据段中的一部分作为堆栈段。所以,当数据段中的CPL字段为3时,SS寄存器就指向该用户数据段中的用户栈;如果数据段中的CPL字段为0时,它就指向内核数据段中的内核栈。注意!这一点很重要,特别是我们以后讲解进程切换的时候,这一个知识你不知道的话,那些内容会让你抓狂的。



除了用户数据段、用户代码段、内核数据段、内核代码段这4个段以外,Linux还使用了其它几个专门的段,下面我们专门来探讨,如图:在单处理器系统中只有一个GDT,而在多处理器系统中每个CPU对应一个GDT。所有的GDT都存放在cpu_gdt_table 数组中,而所有GDT(当初始化gdtr 寄存器时使用)的地址和它们的大小存放在cpu_gdt_descr数组中,这些符号都在文件arch/i386/kernel/head.S中被定义。

我们再把这个知识扩展一下,80x86体系的286以后出现了一个新段,叫做任务状态段(TSS),主要用来保存处理器中各个寄存器的内容。Linux为每个处理器都有一个相应的TSS相关的数据结构,每个TSS相应的线性地址空间都是内核数据段相应线性地址空间的一个小子集。所有的任务状态段都顺序地存放在init_tss数组中;值得特别说明的是,第n个CPU的TSS描述符的Base字段指向init_tss数组的第n个元素。G(粒度)标志被清0,而Limit字段置为0xeb,因为TSS段是236字节长。Type字段置为9或11(可用的32位TSS),且DPL置为0,因为不允许用户态下的进程访问TSS段。

好了,回到刚才的问题,我们谈到了,用户进程要想访问内核提供的数据结构和函数时,须进行切换,即由用户态转向内核态,那么内核栈的地址从何而来?

于是乎,当进程由用户态进入内核态时,必发生中断,因为内核态的CPL优先级高,所以要进行栈的切换。那么就会读tr寄存器以访问该进程(现在还是用户态)的TSS段。随后用TSS中内核态堆栈段ss0和栈指针esp0装载SS和esp寄存器,这样就实现了用户栈到内核栈的切换了。同时,内核用一组mov指令保存所有寄存器到内核态堆栈上,这也包括用户态中ss和esp这对寄存器的内容。

中断或异常处理结束时,CPU控制单元执行iret命令,重新读取栈中的寄存器内容来更新各个CPU寄存器,以重新开始执行用户态进程,此时将会根据栈中。

这里还要强调一下,内核栈的地址只有一个(如果是多CPU架构,则每个CPU一个),其ss和esp保存在TSS结构中,不允许用户态进程访问,Linux描述TSS的格式的数据结构是tss_struct:

struct tss_struct {

unsigned short back_link,__blh;

unsigned long esp0;

unsigned short ss0,__ss0h;

unsigned long esp1;

unsigned short ss1,__ss1h; /* ss1 is used to cache MSR_IA32_SYSENTER_CS */

unsigned long esp2;

unsigned short ss2,__ss2h;

unsigned long __cr3;

unsigned long eip;

unsigned long eflags;

unsigned long eax,ecx,edx,ebx;

unsigned long esp;

unsigned long ebp;

unsigned long esi;

unsigned long edi;

unsigned short es, __esh;

unsigned short cs, __csh;

unsigned short ss, __ssh;

unsigned short ds, __dsh;

unsigned short fs, __fsh;

unsigned short gs, __gsh;

unsigned short ldt, __ldth;

unsigned short trace, io_bitmap_base;

/*

* The extra 1 is there because the CPU will access an

* additional byte beyond the end of the IO permission

* bitmap. The extra byte must be all 1 bits, and must

* be within the limit.

*/

unsigned long io_bitmap[IO_BITMAP_LONGS + 1];

/*

* Cache the current maximum and the last task that used the bitmap:

*/

unsigned long io_bitmap_max;

struct thread_struct *io_bitmap_owner;

/*

* pads the TSS to be cacheline-aligned (size is 0x100)

*/

unsigned long __cacheline_filler[35];

/*

* .. and then another 0x100 bytes for emergency kernel stack

*/

unsigned long stack[64];

} __attribute__((packed));

这就是TSS段的全部内容,不多。每次切换时,内核都更新TSS的某些字段以便想要的CPU控制单元可以安全地检索到它需要的信息,这也是Linux安全性的体现之一。所以,TSS只是反映了CPU上当前进程的特性级别,没有必要运行的进程保留TSS。

linux2.4之前的内核有进程最大数的限制,受限制的原因是,每一个进程都有自已的TSS和LDT,而TSS(任务描述符)和LDT(私有描述符)必须放在GDT中,GDT最大只能存放8192个描述符,除掉系统用的12描述符之外,最大进程数=(8192-12)/2, 总共4090个进程。

从Linux2.4以后,全部进程使用同一个TSS,准确的说是,每个CPU一个TSS,在同一个CPU上的进程使用同一个TSS。TSS的定义在asm-i386/processer.h中,定义如下:

extern struct tss_struct init_tss[NR_CPUS];

在start_kernel()->trap_init()->cpu_init()初始化并加载TSS:

void __init cpu_init (void)

{

int nr = smp_processor_id(); //获取当前cpu

struct tss_struct * t = &init_tss[nr]; //当前cpu使用的tss

t->esp0 = current->thread.esp0; //把TSS中esp0更新为当前进程的esp0

set_tss_desc(nr,t);

gdt_table[__TSS(nr)].b &= 0xfffffdff;

load_TR(nr); //加载TSS

load_LDT(&init_mm.context); //加载LDT

}

我们知道,任务切换(硬切换)需要用到TSS来保存全部寄存器(2.4以前使用jmp来实现切换),中断发生时也需要从TSS中读取ring0的esp0,那么,进程使用相同的TSS,任务切换怎么办?

其实2.4以后不再使用硬切换,而是使用软切换,寄存器不再保存在TSS中了,而是保存在task->thread中,只用TSS的esp0和IO许可位图,所以,在进程切换过程中,只需要更新TSS中的esp0、io_bitmap,代码在sched.c中:

schedule()->switch_to()->__switch_to(),

void fastcall __switch_to(struct task_struct *prev_p, struct task_struct *next_p)

{

struct thread_struct *prev = &prev_p->thread,

*next = &next_p->thread;

struct tss_struct *tss = init_tss + smp_processor_id(); //当前cpu的TSS

/*

* Reload esp0, LDT and the page table pointer:

*/

ttss->esp0 = next->esp0; //用下一个进程的esp0更新tss->esp0

//拷贝下一个进程的io_bitmap到tss->io_bitmap

if (prev->ioperm || next->ioperm) {

if (next->ioperm) {

/*

* 4 cachelines copy ... not good, but not that

* bad either. Anyone got something better?

* This only affects processes which use ioperm().

* [Putting the TSSs into 4k-tlb mapped regions

* and playing VM tricks to switch the IO bitmap

* is not really acceptable.]

*/

memcpy(tss->io_bitmap, next->io_bitmap,

IO_BITMAP_BYTES);

tss->bitmap = IO_BITMAP_OFFSET;

} else

/*

* a bitmap offset pointing outside of the TSS limit

* causes a nicely controllable SIGSEGV if a process

* tries to use a port IO instruction. The first

* sys_ioperm() call sets up the bitmap properly.

*/

tss->bitmap = INVALID_IO_BITMAP_OFFSET;

}

}


2 80x86分段的总结

看晕了吧,我们还是来清理一下80x86段寄存器的知识,这些知识在Linux内核分析中是很重要的,前面已经提到了GDT,这里再把整个段寄存器的知识梳理一下,这样,刚才没看明白的同志应该就有些头绪了。

从80286模式开始,Intel微处理器以两种不同的方式执行地址转换,这两种方式分别称为实模式(real mode)和保护模式(protected mode)。一个逻辑地址由两部分组成:一个段标识符(注意,不是我们课堂上学到的什么“段基址”了哈,升级了!)和一个段内相对地址的偏移量。段标识符是一个16位长的字段,称为段选择符(segment selector),而偏移量是一个32位长的字段。

为了快速方便地找到段选择符,处理器提供段寄存器,段寄存器的唯一目的是存放段选择符的地址(16位,千万要注意,这些段寄存器的内容已经不是什么段基址了)。这些段寄存器称为cs, ss, ds, es, fs和gs。尽管只有6个段寄存器,但程序可以把同一个段寄存器用于不同的目的,方法是先将其值保存在存储器中,用完后再恢复。

6个寄存器中3个有专门的用途:

cs——代码段寄存器,指向包含程序指令的段。

ss——栈段寄存器,指向包含当前程序栈的段。

ds——数据段寄存器,指向包含静态数据或者全局数据的段。

其它三个段寄存器作一般用途,可以指向任意的数据段。

cs寄存器还有一个很重要的功能:它含有一个两位的字段,用以指明CPU的当前特权级(Current PrivilegeLevel,CPL)。值为0代表最高优先级,而值为3代表最低优先级。Linux只用0级和3级,分别称之为内核态和用户态。

每个段由一个8字节的段描述符(Segment Descriptor)表示(参见图),它描述了段的特征(千万要注意,不是段的地址)。段描述符放在全局描述符表(Global Descriptor Table, GDT)或局部描述符表(Local Descriptor Table, LDT)中。通常只定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加的段,就可以有自己的LDT。GDT在主存中的地址和大小存放在gdtr处理器寄存器中,当前正被使用的LDT地址和大小放在ldtr处理器寄存器中。



其意义如下:

Base:包含段的首字节的线性地址

G:粒度标志G:如果该位清为0,段大小以字节为单位,否则以4096字节的倍数计。

Limit:存放段中最后一个内存单元的偏移量,从而决定段的长度。如果G被置为0,段的大小在1个字节到1MB之间变化;否则,将在4KB到4GB之间变化。

S:系统标志S,如果它被清0,则这是一个系统段,存储诸如局部描述符表这种关键的数据结构,否则它是一个普通的代码段或数据段。

Type:描述了段的类型特征和它的存取权限(请看表下面的描述)。

DPL:描述符特权级(Descriptor Privilege Level)字段:用于限制对这个段的存取。它表示为访问这个段而要求的CPU最小的优先级。因此,DPL设为0的段只能当CPL为0时(即在内核态)才是可访问的,而DPL设为3的段对任何CPL值都是可访问的。

P:SegmentPresent标志:等于0表示段当前不在主存中。Linux总是把这个标志(第 47位)设为1,因为它从来不把整个段交换到磁盘上去。

D或B:称为D或B的标志,取决于是代码段还是数据段。D或B的含义在两种情况下稍微有所区别,但是如果段偏移量的地址是32位长,就基本上把它置为1,如果这个偏移量是16位长,它被清0(更详细描述参见Intel使用手册)。

AVL:AVL标志可以由操作系统使用,但是被Linux忽略。

逻辑地址由16位段选择符和32位偏移量组成,段寄存器仅仅存放段选择符。CPU的分段单元执行以下操作(都是机械地转换,了解一下即可):

• 先检查段选择符的TI字段,以决定段描述符保存在哪一个描述符表中。TI字段指明描述符是在GDT中(在这种情况下,分段单元从gdtr寄存器中得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从ldtr寄存器中得到LDT的线性基地址)。

• 从段选择符的index字段计算段描述符的地址,index字段的值乘以8(一个段描述符的大小,其实就是屏蔽掉末尾那三位指示特权级的CPL和指示TI的字段),这个结果与gdtr或ldtr寄存器中的内容相加。

• 把逻辑地址的偏移量与段描述符Base字段的值相加就得到了线性地址。

请注意,CPU有一些与段寄存器相关的的寄存器叫做隐Cache,有些书上也叫不可编程寄存器,用来缓存段描述符。于是乎只有当段寄存器中选择子的内容被改变时才需要执行前两个操作。


3 Linux的指针

当对指向指令或者数据结构的指针进行保存时,内核根本不需要为其设置逻辑地址的段选择符,因为cs寄存器就含有当前的段选择符。例如,当内核调用一个函数时,它执行一条call汇编语言指令,该指令仅指定它逻辑地址的偏移量部分,而段选择符不用设置,其隐含在cs寄存器中了。因为“在内核态执行” 的段只有一种,叫做代码段,由宏_KERNEL_CS定义,所以只要当CPU切换入内核态时足可以将__KERNEL_CS装载入cs。同样的道理也适用于指向内核数据结构的指针(隐含地使用ds寄存器)以及指向用户数据结构的指针(内核显式地使用es寄存器)。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: