深入分析Linux内核源代码-中断基本知识(中)

每天十五分钟,熟读一个技术点,水滴石穿,一切只为渴望更优秀的你!

————零声学院



中断描述符表的初始化

通过上面的介绍,我们知道了 Intel 微处理器对中断和异常所做的工作。下面,我们从

操作系统的角度来对中断描述符表的初始化给予描述。


Linux 内核在系统的初始化阶段要进行大量的初始化工作,其与中断相关的工作有:初

始化可编程控制器 8259A;将中断向量 IDT 表的起始地址装入 IDTR 寄存器,并初始化表中的

每一项。这些操作的完成将在本节进行具体描述。


用户进程可以通过 INT 指令发出一个中断请求,其中断请求向量在 0~255 之间。为了

防止用户使用 INT 指令模拟非法的中断和异常,必须对 IDT 表进行谨慎的初始化。其措施之

一就是将中断门或陷阱门中的 DPL 域置为 0。如果用户进程确实发出了这样一个中断请求,

CPU 会检查出其 CPL(3)与 DPL(0)有冲突,因此产生一个“通用保护”异常。

但是,有时候必须让用户进程能够使用内核所提供的功能(比如系统调用),也就是说

从用户空间进入内核空间,这可以通过把中断门或陷阱门的 DPL 域置为 3 来达到。


1 外部中断向量的设置

前面我们已经提到,Linux 把向量 0~31 分配给异常和非屏蔽中断,而把 32~47 之间的

向量分配给可屏蔽中断,可屏蔽中断的向量是通过对中断控制器的编程来设置的。前面介绍

了 8259A 中断控制器,下面我们通过对其初始化的介绍,来了解如何设置中断向量。

8259A 通过两个端口来进行数据传送,对于单块的 8259A 或者是级连中的 8259A_1 来说,

这两个端口是 0x20 和 0x21。对于 8259A_2 来说,这两个端口是 0xA0 和 0xA1。8259A 有两种

编程方式,一是初始化方式,二是工作方式。在操作系统启动时,需要对 8959A 做一些初始

化工作,这就是初始化方式编程。

先简单介绍一下 8259A 内部的 4 个中断命令字(ICW)寄存器的功能,它们都是用来启

动初始化编程的。

o ICW1:初始化命令字。

o ICW2:中断向量寄存器,初始化时写入高 5 位作为中断向量的高五位,然后在中断

响应时由 8259 根据中断源(哪个管脚)自动填入形成完整的 8 位中断向量(或叫中断

类型号)。

o ICW3:8259 的级连命令字,用来区分主片和从片。

o ICW4:指定中断嵌套方式、数据缓冲选择、中断结束方式和 CPU 类型。

8259A 初始化的目的是写入有关命令字,8259A 内部有相应的寄存器来锁存这些命令字,

以控制 8259A 工作。有关的硬件知识笔者就不详细描述了,请读者查阅有关可编程中断控制

器 的 资 料 , 我 们 只 具 体 把 Linux 对 8259A 的 初 始 化 讲 解 一 下 , 代 码 在

/arch/i386/kernel/i8259.c 的函数 init_8259A()中:

outb(0xff, 0x21); /* 送数据到工作寄存器 OCW1(又称中断屏蔽字),

屏蔽所有外部中断, 因为此时系统尚未初始化完毕,

outb(0xff, 0xA1); 不能接收任何外部中断请求 */

outb_p(0x11, 0x20); /*送 0x11 到 ICW1(通过端口 0x20),启动初始化编

程。0x11 表示外部中断请求信号为上升沿有效,系统中有多片 8295A 级连,还表示要向 ICW4

送数据 */

outb_p(0x20 + 0, 0x21); /* 送 0x20 到 ICW2,写入高 5 位作为中断向量的

高 5 位,低 3 位根据中断源(管脚)填入中断号 0~7,因此把 IRQ0-7 映射到向量 0x20-0x27

*/

outb_p(0x04, 0x21); /* 送 0x04 到 ICW3,ICW3 是 8259 的级连命令字, 0x04

表示 8259A-1 是主片 */

outb_p(0x11, 0xA0); /* 用 ICW1 初始化 8259A-2 */

outb_p(0x20 + 8, 0xA1); /* 用 ICW2 把 8259A-2 的 IRQ0-7 映射到 0x28-0x2f

*/

outb_p(0x02, 0xA1); /* 送 0x04 到 ICW3。表示 8259A-2 是从片,并连接在 8259A_1

的 2 号管脚上*/

outb_p(0x01, 0xA1); /* 把 0x01 送到 ICW4 */

最后一句有 4 方面含义:①中断嵌套方式为一般嵌套方式。当某个中断正在服务时,本

级中断及更低级的中断都被屏蔽,只有更高级的中断才能响应。注意,这对于多片 8259A 级

连的中断系统来说,当某从片中一个中断正在服务时,主片即将这个从片的所有中断屏蔽,

所以此时即使本片有比正在服务的中断级别更高的中断源发出请求,也不能得到响应,即不

能中断嵌套。②8259A 数据线和系统总线之间不加三态缓冲器。一般来说,只有级连片数很

多时才用到三态缓冲器;③中断结束方式为正常方式(非自动结束方式)。即在中断服务结

束时(中断服务程序末尾),要向 8259A 芯片发送结束命令字 EOI(送到工作寄存器 OCW2 中),

于是中断服务寄存器 ISR 中的当前服务位被清 0,EOI 命令字的格式有多种,在此不详述;④

CPU 类型为 x86 系列。


outb_p()函数就是把第一个操作数拷贝到由第二个操作数指定的 I/O 端口,并通过一个

空操作来产生一个暂停。


这里介绍了 8259A 初始化的主要工作。最后要说明的是:IBM PC 机的 BIOS 中固化有对

中断控制器的初始化程序段,在计算机加电时,这段程序自动执行,读者感兴趣可以查阅资

料看看它的源代码。典型的 PC 机将外部中断的中断向量分配为:08H~0FH,70H~77H。但是

Linux 对 8259A 作了重新初始化,修改了外部中断的中断向量的分配(20H~2FH),使中断向

量的分配更加合理。



当中断描述符表 IDT 的预初始化

计算机运行在实模式时,IDT 被初始化并由 BIOS 使用。然而,一旦真正进入了 Linux

内核,IDT 就被移到内存的另一个区域,并进行进入实模式的初步初始化。

1.中断描述表寄存器 IDTR 的初始化

用汇编指令 LIDT 对中断向量表寄存器 IDTR 进行初始化,其代码在

arch/i386/boot/setup.S 中:

lidt idt_48 # load idt with 0,0

idt_48:

.word 0 # idt limit = 0

.word 0, 0 # idt base = 0L

2.把 IDT 表的起始地址装入 IDTR

用汇编指令 LIDT 装入 IDT 的大小和它的地址(在 arch/i386/kernel/head.S 中):

#define IDT_ENTRIES 256

.globl SYMBOL_NAME(idt)

lidt idt_descr

idt_descr:

.word IDT_ENTRIES*8-1 # idt contains 256 entries

SYMBOL_NAME(idt):

.long SYMBOL_NAME(idt_table)

其中 idt 为一个全局变量,内核对这个变量的引用就可以获得 IDT 表的地址。表的长度

为 256×8=2048 字节。

3.用 setup_idt()函数填充 idt_table 表中的 256 个表项

我们首先要看一下 idt_table 的定义(在 arch/i386/kernel/traps.c 中):

struct desc_struct idt_table[256] __attribute__((__section__(".data.idt"))) = { {0,

0}, };

desc_struct 结构定义为:

struct desc_struct {

unsigned long a,b }

对 idt_table 变量还定义了其属性(__attribute__),__section__是汇编中的“节”,

指定了 idt_table 的起始地址存放在数据节的 idt 变量中,如上面第“2.把 IDT 表的起始地

址装入 IDTR”所述。

在对 idt_table 表进行填充时,使用了一个空的中断处理程序 ignore_int()。因为现在

处于初始化阶段,还没有任何中断处理程序,因此用这个空的中断处理程序填充每个表项。

ignore_int()是一段汇编程序(在 head.S 中):

ignore_int:

cld #方向标志清 0,表示串指令自动增长它们的索引寄存器(esi 和

edi)

pushl %eax

pushl %ecx

pushl %edx

pushl %es

pushl %ds

movl $(__KERNEL_DS),%eax

movl %eax,%ds

movl %eax,%es

pushl $int_msg

call SYMBOL_NAME(printk)

popl %eax

popl %ds

popl %es

popl %edx

popl %ecx

popl %eax

iret

int_msg:

.asciz "Unknown interrupt\n"

ALIGN

该中断处理程序模仿一般的中断处理程序,执行如下操作:

o 在栈中保存一些寄存器的值;

o 调用 printk()函数打印“Unknown interrupt”系统信息;



中断向量表的最终初始化

在对中断描述符表进行预初始化后, 内核将在启用分页功能后对 IDT 进行第二遍初始化,

也就是说,用实际的陷阱和中断处理程序替换这个空的处理程序。一旦这个过程完成,对于

每个异常,IDT 都由一个专门的陷阱门或系统门,而对每个外部中断,IDT 都包含专门的中断

门。

1.IDT 表项的设置

IDT 表项的设置是通过_set_gaet()函数实现的,这与 IDT 表的预初始化比较相似,但这

里使用的是嵌入式汇编,因此,理解起来比较困难。在此,我们给出函数源码(在 traps.c

中)及其解释:

#define _set_gate(gate_addr,type,dpl,addr) \

do { \

int __d0, __d1; \

__asm__ __volatile__ ("movw %%dx,%%ax\n\t" \

"movw %4,%%dx\n\t" \

"movl %%eax,%0\n\t" \

"movl %%edx,%1" \

:"=m" (*((long *) (gate_addr))), \

"=m" (*(1+(long *) (gate_addr))), "=&a" (__d0), "=&d" (__d1) \

:"i" ((short) (0x8000+(dpl<<13)+(type<<8))), \

"3" ((char *) (addr)),"2" (__KERNEL_CS << 16)); \

} while (0)

这是一个带参数的宏定义,其中,gate_addr 是门的地址,type 为门类型,dpl 为请求

特权级,addr 为中断处理程序的地址。对这段嵌入式汇编代码的说明如下:

o 输出部分有 4 个变量,分别与%1、%2、%3 及%4 相结合,其中,%0 与 gate_addr

结合,1%与(gate_aggr+1)结合,这两个变量都存放在内存;2%与局部变量__d0 结合,

存放在 eax 寄存器中;3%与__d1 结合,存放在 edx 寄存器中。

o 输入部分有 3 个变量。由于输出部分已定义了 0%~3%,因此,输入部分的第一个变

量为 4%,其值为“0x8000+(dpl<<13)+(type<<8”,而后面两个变量分别等价于输出部分

的%3(edx)和 2%(eax),其值分别为“addr”和“__KERNEL_CS << 16”

o 有了参数的这种对照关系,再参考前面的 set_idt()函数,就不难理解那 4 条 mov 语句

了。

下面我们来看如何调用_set_get()函数来给 IDT 插入门:

void set_intr_gate(unsigned int n, void *addr)

{

_set_gate(idt_table+n,14,0,addr);

}

在第 n 个表项中插入一个中断门。这个门的段选择符设置成代码段的选择符

(__KERNEL_CS),DPL 域设置成 0,14 表示 D 标志位为 1 而类型码为 110,所以 set_intr_gate()

设置的是中断门,偏移域设置成中断处理程序的地址 addr。

static void __init set_trap_gate(unsigned int n, void *addr)

{

_set_gate(idt_table+n,15,0,addr);

}

在第 n 个表项中插入一个陷阱门。这个门的段选择符设置成代码段的选择符,DPL 域设

置成 0,15 表示 D 标志位为 1 而类型码为 111,所以 set_trap_gate()设置的是陷阱门,偏移

域设置成异常处理程序的地址 addr。

static void __init set_system_gate(unsigned int n, void *addr)

{

_set_gate(idt_table+n,15,3,addr);

}

在第 n 个表项中插入一个系统门。这个门的段选择符设置成代码段的选择符,DPL 域设

置成 3,15 表示 D 标志位为 1 而类型码为 111,所以 set_system_gate()设置的也是陷阱门,

但因为 DPL 为 3,因此,系统调用在用户空间可以通过“INT0X80”顺利穿过系统门,从而进

入内核空间。

2.对陷阱门和系统门的初始化

trap_init()函数就是设置中断描述符表开头的 19 个陷阱门,如前所说,这些中断向量

都是 CPU 保留用于异常处理的:

set_trap_gate(0,÷_error);

set_trap_gate(1,&debug);

set_intr_gate(2,&nmi);

set_system_gate(3,&int3); /* int3-5 can be called from all */

set_system_gate(4,&overflow);

set_system_gate(5,&bounds);

set_trap_gate(6,&invalid_op);

set_trap_gate(7,&device_not_available);

set_trap_gate(8,&double_fault);

set_trap_gate(9,&coprocessor_segment_overrun);

set_trap_gate(10,&invalid_TSS);

set_trap_gate(11,&segment_not_present);

set_trap_gate(12,&stack_segment);

set_trap_gate(13,&general_protection);

set_intr_gate(14,&page_fault);

set_trap_gate(15,&spurious_interrupt_bug);

set_trap_gate(16,&coprocessor_error);

set_trap_gate(17,&alignment_check);

set_trap_gate(18,&machine_check);

set_trap_gate(19,&simd_coprocessor_error);

set_system_gate(SYSCALL_VECTOR,&system_call);

在对陷阱门及系统门设置以后,我们来看一下中断门的设置。

3.中断门的设置

下面介绍的相关代码均在 arch/I386/kernel/i8259.c 文件中,其中中断门的设置是由

init_IRQ( )函数中的一段代码完成的:

for (i = 0; i< NR_IRQS; i++) {

int vector = FIRST_EXTERNAL_VECTOR + i;

if (vector != SYSCALL_VECTOR)

set_intr_gate(vector, interrupt[i]);

其含义比较明显:从 FIRST_EXTERNAL_VECTOR 开始,设置 NR_IRQS 个 IDT 表项。常数

FIRST_EXTERNAL_VECTOR 定义为 0x20,而 NR_IRQS 则为 224,即中断门的个数。注意,必须

跳过用于系统调用的向量 0x80,因为这在前面已经设置好了。

这里,中断处理程序的入口地址是一个数组 interrupt[],数组中的每个元素是指向中

断处理函数的指针。我们来看一下编码的作者如何巧妙地避开了繁琐的文字录入,而采用统

一的方式处理多个函数名。

#define IRQ(x,y) \

IRQ##x##y##_interrupt

#define IRQLIST_16(x) \

IRQ(x,0), IRQ(x,1), IRQ(x,2), IRQ(x,3), \

IRQ(x,4), IRQ(x,5), IRQ(x,6), IRQ(x,7), \

IRQ(x,8), IRQ(x,9), IRQ(x,a), IRQ(x,b), \

IRQ(x,c), IRQ(x,d), IRQ(x,e), IRQ(x,f)

void (*interrupt[NR_IRQS])(void) = IRQLIST_16(0x0)

其中,“##”的作用是把字符串连接在一起。经过 gcc 预处理,IRQLIST_16(0x0)被

替换为 IRQ0x00_interrupt,IRQ0x01_interrupt,IRQ0x02_interrupt…IRQ0x0f_interrupt。

到此为止,我们已经介绍了 15 个陷阱门、4 个系统门和 16 个中断门的设置。内核代码

中还有对其他中断门的设置,在此就不一一介绍。



每日分享15分钟技术摘要选读,关注一波,一起保持学习动力!

原文链接:,转发请注明来源!