每天十五分钟,熟读一个技术点,水滴石穿,一切只为渴望更优秀的你!
————零声学院
中断描述符表的初始化
通过上面的介绍,我们知道了 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)寄存器的功能,它们都是用来启
动初始化编程的。
• ICW1:初始化命令字。
• ICW2:中断向量寄存器,初始化时写入高 5 位作为中断向量的高五位,然后在中断
响应时由 8259 根据中断源(哪个管脚)自动填入形成完整的 8 位中断向量(或叫中断
类型号)。
• ICW3:8259 的级连命令字,用来区分主片和从片。
• 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 机将外部中断的中断向量分配为:08H0FH,70H77H。但是
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
该中断处理程序模仿一般的中断处理程序,执行如下操作:
• 在栈中保存一些寄存器的值;
• 调用 printk()函数打印“Unknown interrupt”系统信息;
• 从栈中恢复寄存器的内容;
• 执行 iret 指令以恢复被中断的程序。
实际上,ignore_int()处理程序应该从不执行。如果在控制台或日志文件中出现了
“Unknown interrupt”消息,说明要么是出现了一个硬件问题(一个 I/O 设备正在产生没有
预料到的中断),要么就是出现了一个内核问题(一个中断或异常未被恰当地处理)。
最后,我们来看 setup_idt()函数如何对 IDT 表进行填充:
/*
- setup_idt
- sets up a idt with 256 entries pointing to
- ignore_int, interrupt gates. It doesn’t actually load
- idt - that can be done only after paging has been enabled
- and the kernel moved to PAGE_OFFSET. Interrupts
- are enabled elsewhere, when we can be relatively
- sure everything is ok.
/
setup_idt:
lea ignore_int,%edx /计算 ignore_int 地址的偏移量,并将其装入%edx/
movl $(__KERNEL_CS << 16),%eax / selector = 0x0010 = cs /
movw %dx,%ax
movw $0x8E00,%dx / interrupt gate - dpl=0, present /
lea SYMBOL_NAME(idt_table),%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
ret
这段程序的理解要对照门描述符的格式。8 个字节的门描述符放在两个 32 位寄存器 eax
和 edx 中,如图 3.4 所示,从 rp_sidt 开始的那段程序是循环填充 256 个表项。
• 从栈中恢复寄存器的内容;
• 执行 iret 指令以恢复被中断的程序。
实际上,ignore_int()处理程序应该从不执行。如果在控制台或日志文件中出现了
“Unknown interrupt”消息,说明要么是出现了一个硬件问题(一个 I/O 设备正在产生没有
预料到的中断),要么就是出现了一个内核问题(一个中断或异常未被恰当地处理)。
最后,我们来看 setup_idt()函数如何对 IDT 表进行填充:
/ - setup_idt
- sets up a idt with 256 entries pointing to
- ignore_int, interrupt gates. It doesn’t actually load
- idt - that can be done only after paging has been enabled
- and the kernel moved to PAGE_OFFSET. Interrupts
- are enabled elsewhere, when we can be relatively
- sure everything is ok.
/
setup_idt:
lea ignore_int,%edx /计算 ignore_int 地址的偏移量,并将其装入%edx/
movl $(__KERNEL_CS << 16),%eax / selector = 0x0010 = cs /
movw %dx,%ax
movw $0x8E00,%dx / interrupt gate - dpl=0, present */
lea SYMBOL_NAME(idt_table),%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
ret
这段程序的理解要对照门描述符的格式。8 个字节的门描述符放在两个 32 位寄存器 eax
和 edx 中,如图 3.4 所示,从 rp_sidt 开始的那段程序是循环填充 256 个表项。
中断向量表的最终初始化
在对中断描述符表进行预初始化后, 内核将在启用分页功能后对 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 为中断处理程序的地址。对这段嵌入式汇编代码的说明如下:
• 输出部分有 4 个变量,分别与%1、%2、%3 及%4 相结合,其中,%0 与 gate_addr
结合,1%与(gate_aggr+1)结合,这两个变量都存放在内存;2%与局部变量__d0 结合,
存放在 eax 寄存器中;3%与__d1 结合,存放在 edx 寄存器中。
• 输入部分有 3 个变量。由于输出部分已定义了 0%~3%,因此,输入部分的第一个变
量为 4%,其值为“0x8000+(dpl<<13)+(type<<8”,而后面两个变量分别等价于输出部分
的%3(edx)和 2%(eax),其值分别为“addr”和“__KERNEL_CS << 16”
• 有了参数的这种对照关系,再参考前面的 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分钟技术摘要选读,关注一波,一起保持学习动力!