本文转自网络文章,内容均为非盈利,版权归原作者所有。
转载此文章仅为个人收藏,分享知识,如有侵权,马上删除。
原文作者:jmpcall
专栏地址:https://zhuanlan.kanxue.com/user-815036.htm
1. 概念了解
有时候,应用程序开发的经验,很容易使我们的视野,停留在单个进程中,反而阻碍理解。我的方法是,把整个系统看成一个"大程序",这个程序最开始只有内核部分,随着执行,它又动态加载了一些指令和数据,作为自身的"生长部分",也就是说,把"进程"理解为整个"大程序"的"生长部分"。这样理解的好处是,会让视野从单个进程中走出来,从而才能看得清整个系统的运行逻辑:
① 任何时刻,这个"大程序"只有一条指令在被CPU执行。
② 系统启动阶段,从内核的代码开始执行,内核运行过程中,会动态扩展自己的指令和数据,并为"生长部分"分配独立的管理结构(上次从该"生长部分"跳转走的位置等),以及"生长部分"本身独立使用的资源("虚拟地址-物理地址"映射表等)。
③ 刚通电时,CPU为"实模式"状态,通过执行内核代码,切换到"保护模式",使得程序中的地址,要经过一次映射,才能得到物理地址,为各个"生长部分"的内存隔离,建立了基础。刚切换到"保护模式"时,CPU可以执行特权指令,并逐步创建了一些进程,由于内核开发人员,无法预测计算机用户,懂不懂自己去控制各种硬件,所以内核封装了大量接口给"生长部分"使用,同时也无法预测用户的程序会不会有bug,所以在跳转到"生长部分"之前,将CPU的特权级别修改为低级别。
这样,疑问就很自然的产生了:
- 某个"生长部分"执行while(1)死循环,为什么仍然可以执行到其它"生长部分"?
CPU硬件层,提供了"时钟中断"功能,内核在启动阶段设置"时钟中断"处理函数并开启"时钟中断"功能,之后每隔一定时间,无论CPU执行到"大程序"的哪个地方,都会被"时钟中断"逻辑,保存CPU当时的执行状态(当时执行的指令位置、特权级别等),然后跳转到"时钟中断"处理函数,进一步再跳转到其它"生长部分"执行。
- "生长部分"有bug,比如执行"除0"操作,是如何coredump的?
软件中使用的指令集,都是由硬件提供的,而硬件层的设计保证了,当"除法指令"的除数为0时,保存CPU当时的执行状态,跳转到"除0"的异常处理函数执行,和"时钟中断"处理函数一样,各种异常处理函数,也都是由内核实现并在启动阶段设置。
- "生长部分"如何调用内核接口?
由于内核开发人员,只能掌控自己实现的代码,无法预测用户的代码,所以,整个"大程序"在往"生长部分"跳转的同时,一定会降低CPU权限,跳转回内核代码的同时,也必须提升CPU权限。有一种方法是,在软件层保证,每个中断处理函数,都在特权指令之前,执行提升CPU权限的指令,但这样,就增加了内核实现的负担,同时也会让中断处理函数的代码看起来很臃肿。
所以,硬件层保证了这一点,回忆一下Linux内核笔记001介绍过的段描述符:
如果type字段的二进程值为100/101/110/111,硬件层就会按照"门"的含义解释:
利用"门",应用程序中执行一条指令(int/call/jmp),就可以同时完成"向内核代码跳转"和"提升CPU权限"两个动作,具体过程稍后详细学习,这里先根据上述内容,做个小小的展开,这样稍后深入到细节分析时,就不会思维混乱了:
① 内核代码,一部分在刚开机时执行,没有进程上下文,一部分在创建进程后,被进程"调用"执行,有进程上下文;
② 用户态是通过"门",切换到内核态,根据触发条件,分为中断、异常、陷阱。时钟中断,跟当时的进程上下文无关,CPU异常处理函数内部,可能又会出现缺页等异常,或者发生时钟中断,就会出现"中断嵌套"的情况。
2. X86 CPU对中断的硬件支持
- 任务门
80386提供的4种"门",结构图都可以从上述内容看到,它们有个共同的特点,就是对应段描述符"B15-B0"的位置,都被CPU硬件层设计解释为段选择子,也就是说,通过"门",最终得到的仍然是段描述符,只是多走了一道而已。相比于其它三种"门",任务门的段描述符,指向长度固定为104的段,并且整个段被CPU硬件层设计解释为TSS结构,也正是用于得到段的首地址,就能得到TSS结构的地址,所以对应其它三种"门"中表示位移的位置,空闲不用。
① 什么是TSS?
由于CPU硬件设计阶段,无法完全预料内核会放弃使用哪些硬件特征,或者由于历史原因和对兼容性的考虑,会存在一些软件层不愿意使用,但又无法关闭的功能,对于这样的功能,软件层一般只是"假装"使用, 即逻辑上并没有使用,纯粹保证硬件内部执行不出错而已。比如:80386寻址过程中,段式寻址逻辑是必然要执行的,同时段式寻址的设计意图决定了,各个进程的虚拟空间,必须用独立的LDT管理,内核也必须用GDT管理各个LDT所在的段。所以,尽管Linux内核只想用"页"记录进程的虚拟空间使用情况,但仍然要为各个进程安排一个LDT,并在GDT设置一个全局描述符指定它,只不过所有进程都使用同一个LDT,并且LDT中始终只有一个局部描述符,指向0地址开始的整个4G虚拟空间。
本篇笔记开始部分,将整个系统看作一个"大程序",当时也一步步分析了,"生长部分"需要有资源记录本身的"虚拟地址-物理地址"映射关系,LDT正是用于这个目的,另外还需要其它管理信息,比如,上次从该"生长部分"跳转走时的位置、各个寄存器的值等。为此,CPU硬件层,还要求内核为每个进程,设置一个TSS结构(定义如下),每个进程的TSS要独占一个段,并在GDT中设置一个全局描述符。
struct tss_struct {
unsigned short back_link,__blh;
unsigned long esp0;
unsigned short ss0,__ss0h;
unsigned long esp1;
unsigned short ss1,__ss1h;
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, bitmap;
unsigned long io_bitmap[IO_BITMAP_SIZE+1];
/*
* pads the TSS to be cacheline-aligned (size is 0x100)
*/
unsigned long __cacheline_filler[5];
};
② TSS是不是只能通过任务门访问?
80386任务门的本质,就是硬件层设计,将所含的段选择子,解释为TSS段描述符:执行int/call/jmp指令时,如果在指令寻址过程中,遇到任务门,CPU就会将当前的状态保存到当前进程的TSS结构(TR寄存器始终指向当前进程的TSS),并让TR寄存器指向新的TSS,从而在硬件层完成进程切换。不过,等到学完第四章"进程与进程调度",就会发现,Linux内核并不使用任务门进行进程切换,那为什么从Linux内核中,还是能看到TSS相关的代码呢?那是由于,要从"生长部分"主动或被动跳转到内核代码,根据存储在内核空间的进程管理信息,才可以进行进程切换,这个过程会发生CPU权限的变化,而CPU在进入新的运行级别时,会自动从当前进程的TSS中装入相应运行级别的SS和ESP,所以Linux内核仍然要为每个进程设置TSS,并且要正确设置esp0、ss0成员的值,保证切换到内核态时,CPU内部执行不出错。
#define INIT_TSS { \
0,0, /* back_link, __blh */ \
sizeof(init_stack) + (long) &init_stack, /* esp0 */ \
__KERNEL_DS, 0, /* ss0 */ \
0,0,0,0,0,0, /* stack1, stack2 */ \
0, /* cr3 */ \
0,0, /* eip,eflags */ \
0,0,0,0, /* eax,ecx,edx,ebx */ \
0,0,0,0, /* esp,ebp,esi,edi */ \
0,0,0,0,0,0, /* es,cs,ss */ \
0,0,0,0,0,0, /* ds,fs,gs */ \
__LDT(0),0, /* ldt */ \
0, INVALID_IO_BITMAP_OFFSET, /* tace, bitmap */ \
{~0, } /* ioperm */ \
}
- 中断门、陷阱门、调用门
① 和任务门相比,中断门/陷阱门/调用门最终指向的都是某个代码段,并且都要指定对应的执行函数,在整个代码段的多少偏移处;
② 中断门和陷阱门相比,进入中断门后,CPU会将EFLAGS寄存器中IF标志位清0,即不再响应新的中断,可以用于防止中断嵌套,否则如果时钟中断处理函数执行时间,大于时钟中断产生间隔,就会一直执行时钟中断函数;
③ 80386的各种"门",是为不同的使用场合设计的,权限检查过程各不相同且复杂("门"本身有DPL,"门"所指段描述符也有DPL),但Linux内核不使用任务门(主要考虑性能),也几乎不使用调用门(简单就是美);
④ 进入中断处理函数时,CPU要将当前的EFLAGS寄存器、CS:EIP内容,压入栈中,如果是由异常引起的,还要压入一个错误码,如果CPU运行级别改变,要先根据TSS切换到新级别对应的栈,并且先将切换前的CS:EIP压入栈中(下一篇笔记,就会看到这些压栈操作的意义);
3. 中断向量表IDT的初始化
硬件设计了GDT/LDT寄存器,指向段描述符表,同样,也设计了IDTR寄存器,指向中断向量表("门描述符"表)。
① INT指令的参数,即为IDT的索引;
② IDT前20个中断向量,完全由CPU硬件层使用,必须按照CPU的硬件规范设置,比如缺页异常时,CPU会自动穿过14号门,如果内核启动阶段,在14号向量设置的门,不能进入缺页异常处理函数,系统就会运行出错。
③ i386的系统结构,支持256个中断向量,软件层从0x20号向量开始使用,共224个,其中80号向量用于系统调用,所以才可以通过"INT 80",从用户态切换到内核态。
- start_kernel()调用trap_init()、init_IRQ()分别初始化硬件层、软件层使用的部分
start_kernel()
| // 最前面19个中断向量,由硬件使用,比如缺页异常时,CPU自动穿过14号向量对应的门
|- trap_init()
| |- set_trap_gate()
| | | // 15:1111 => D:1,type:111,陷阱门
| | | // DPL:0 => 防止用户态使用INT指令穿过该门
| | |- _set_gate(idt_table+n,15,0,addr)
| |- set_intr_gate()
| | | // 14:1110 => D:1,type:110,中断门(与陷阱门的唯一区别是:不会中断嵌套)
| | | // 用于外设中断
| | |- _set_gate(idt_table+n,14,0,addr)
| |- set_system_gate()
| | | // DPL:3 => 用户态可以使用INT指令穿过该门(系统调用)
| | | // 用户态执行系统调用,最终就是通过"INT 80",切换到内核态
| | |- _set_gate(idt_table+n,15,3,addr)
| |- set_call_gate()
| | | // 12:1100 => D:1,type:100,调用门
| | | // iBCS、Solaris/x86支持通过调用门进入系统调用,为了保证在这些系统中编译的程序,可以在Linux上执行
| | |- _set_gate(a,12,3,addr)
|- init_IRQ() // 本篇笔记最后一节,详细分析
- trap_init()函数分析
不管设置哪种门,最终都调用了_set_gate(),它是个宏函数:
Linux内核笔记003专门学习过AT&T格式汇编,以及它嵌入在C语言中的形式,这个宏函数不难分析:
① 输入部,将3,edx,__d1初始化为addr,将2,eax,__d0初始化为__KERNEL_CS<<16;
② "movw %%dx,%%ax":设置AX为DX的值,即addr低16位;
③ "movw %4,%%dx":设置DX为0x8000+(dpl<<13)+(type<<8));
④ "movl %%eax,%0"
⑤ "movl %%edx,%1"
以上将"门描述符"的高低32位内容,构造到了EDX、DAX寄存器中,最后两条指令将"门描述符"写入gate_addr指向的内存中。
4. 中断请求队列的初始化
软件层从0x20号向量开始使用,80号向量用于系统调用,还剩223个可以使用,Linux内核的使用方式为:
启动时,为各个门设置处理函数"IRQ0xXX_interrupt->common_interrupt->do_IRQ()",其中IRQ0xXX_interrupt、common_interrupt的代码中,没有RET指令,并且不是用CALL指令跳转,而是用JMP指令跳转的,可以把三者看作一个整体"do_IRQ_XX()",不过,"do_IRQ_XX()"只是执行最终处理函数的"通道",最终处理函数,是挂在irq_desc[0xXX].action链表中的,由"do_IRQ_XX()"遍历间接执行。
这样,通过一个中断向量,就可以执行多个不同的函数。比如,两个不同的驱动中,都希望响应某个相同的中断,它们的处理函数,就可以共用同一个中断向量。
另外,外设与中断控制器,位置关系大概如下:
PCI驱动显然知道,各个外设插槽与中断控制器引脚的对应关系,相应的,外设驱动将中断信号发送到CPU,对应的IRQ号为多少,也是可以获取的,也就可以,将中断控制器提供的一套引脚操作函数,记录到相应的irq_desc[0xXX].handler,供外设驱动从中断控制器层,对各个"中断通道"进程控制。
介绍到这里,init_IRQ()函数就不难分析了:
init_IRQ()
|- init_ISA_irqs()
| | // 初始化i8259A中断控制器
| |- init_8259A()
| |- for (0->15)
| | |- irq_desc[i].handler = &i8259A_irq_type
| /*
| * interrupt函数指针数组:
| * void (*interrupt[NR_IRQS])(void) = {
| * IRQLIST_16(0x0),
| * }; |
| * |- IRQ(0x0,0)
| * | |- IRQ0x00_interrupt
| * |- ..
| * |- IRQ(0x0,f)
| * |- IRQ0x0f_interrupt
| */
| /*
| * IRQ0xXX_interrupt()函数定义:
| * BUILD_16_IRQS(0x0)
| * |- BI(0x0,0)
| * | |- BUILD_IRQ(0x00)
| * | | |- IRQ_NAME(0x00)
| * | | |- IRQ_NAME2(IRQ0x00)
| * | | |- IRQ0x00_interrupt(void)
| * | |- asmlinkage void IRQ0x00_interrupt(void)
| * | | __asm__( \
| * | | ".align 0"
| * | | "IRQ0x00_interrupt:"
| * | | "pushl $0x00-256"
| * | | "jmp common_interrupt");
| * |- ..
| * |- BI(0x0,f)
| * |- asmlinkage void IRQ0x0f_interrupt(void)
| */
| // 设置20~35号中断向量对应的门,处理函数分别为IRQ0x00_interrupt(), .., IRQ0x0f_interrupt()
|- for (0->15)
| |- set_intr_gate()