INTERRUPT
中断是硬件和软件交互的一种机制,可以说整个操作系统,整个架构都是由中断来驱动的。中断的机制分为两种,中断和异常,中断通常为
I
O
IO
IO 设备触发的异步事件,而异常是
C
P
U
CPU
CPU 执行指令时发生的同步事件。本文主要来说明
I
O
IO
IO 外设触发的中断,总的来说一个中断的起末会经历设备,中断控制器,CPU 三个阶段:设备产生中断信号,中断控制器翻译信号,CPU 来实际处理信号。
本文用
x
v
6
xv6
xv6 的实例来讲解多处理器下的中断机制,从头至尾的来看一看,中断经历的三个过程。其中第一个阶段设备如何产生信号不讲,超过了操作系统的范围,也超过了我的能力范围。各种硬件外设有着自己的执行逻辑,有各种形式的中断触发机制,比如边沿触发,电平触发等等。总的来说就是向中断控制器发送一个中断信号,中断控制器再作翻译发送给
C
P
U
CPU
CPU,
C
P
U
CPU
CPU 再执行中断服务程序对中断进行处理。
中断控制器
说到中断控制器,是个什么东西?中断控制器可以看作是中断的代理,外设是很多的,如果没有一个中断代理,外设想要给
C
P
U
CPU
CPU 发送中断信号来处理中断,那只能是外设连接在
C
P
U
CPU
CPU 的管脚上,
C
P
U
CPU
CPU 的管脚是很宝贵的,不可能拿出那么多管脚去连接外设。所以就有了中断控制器这个中断代言人,所有的
I
O
IO
IO 外设连接其上,发送中断请求时就向中断控制器发送信号,中断控制器再通知 CPU,如此便解决了上述问题。
中断控制器有很多,前文讲过 PIC ?,PIC 只用于单处理器,对于如今的多核多处理器时代,PIC 无能为力,所以出现了更高级的中断控制器 APIC ,APIC (
A
d
v
a
n
c
e
d
?
P
r
o
g
r
a
m
m
a
b
l
e
?
I
n
t
e
r
r
u
p
t
?
C
o
n
t
r
o
l
l
e
r
Advanced\ Programmable\ Interrupt\ Controller
Advanced?Programmable?Interrupt?Controller) 高级可编程中断控制器,APIC 分成两部分 LAPIC 和 IOAPIC ,前者 LAPIC 位于
C
P
U
CPU
CPU 内部,每个
C
P
U
CPU
CPU 都有一个 LAPIC ,后者 IOAPIC 与外设相连。外设发出的中断信号经过 IOAPIC 处理之后发送给 LAPIC ,再由 LAPIC 决定是否交由
C
P
U
CPU
CPU 进行实际的中断处理。
可以看出每个
C
P
U
CPU
CPU 上有一个 LAPIC ,IOAPIC 是系统芯片组一部分,各个中断消息通过总线发送接收。关于 APIC 的内容很多也很复杂,详细描述的可以参考
i
n
t
e
l
intel
intel 开发手册卷三,本文不探讨其中的细节,只在上层较为抽象的层面讲述,理清 APIC 模式下中断的过程。
计算机启动的时候要先对 APIC 进行初始化,后续才能正确使用,下面来看看 APIC 在一种较为简单的工作模式下的初始化过程:
IOAPIC
初始化 IOAPIC 就是设置 IOAPIC 的寄存器,IOAPIC 寄存器一览:
所以有了以下定义:
#define REG_ID 0x00
#define REG_VER 0x01
#define REG_TABLE 0x10
但是这些寄存器是不能直接访问的,需要通过另外两个映射到内存的寄存器来读写上述的寄存器。
内存映射的两个寄存器
这两个寄存器是内存映射的,IOREGSEL ?,地址为
0
x
F
E
C
0
?
0000
0xFEC0\ 0000
0xFEC0?0000;?IOWIN? ,地址为
0
x
F
E
C
0
?
0010
h
0xFEC0\ 0010h
0xFEC0?0010h。IOREGSEL 用来指定要读写的寄存器,然后从 IOWIN 中读写。也就是常说的 index/data 访问方式,或者说
a
d
r
e
s
s
/
d
a
t
a
adress/data
adress/data,用 index 端口指定寄存器,从 data 端口读写寄存器,data 端口就像是所有寄存器的窗口。
而所谓内存映射 ,就是把这些寄存器看作内存的一部分,读写内存,就是读写寄存器,可以用访问内存的指令比如 mov 来访问寄存器。还有一种是 IO端口映射 ,这种映射方式是将外设的 IO端口(外设的一些寄存器) 看成一个独立的地址空间,访问这片空间不能用访问内存的指令,而需要专门的 in/out 指令来访问。
通过 IOREGSEL 和 IOWIN 既可以访问到 IOAPIC 所有的寄存器,所以结构体
i
o
a
p
i
c
ioapic
ioapic 如下定义:
struct ioapic {
uint reg;
uint pad[3];
uint data;
};
填充
12
12
12 字节是因为 IOREGSEL 在
0
x
F
E
C
0
?
0000
0xFEC0\ 0000
0xFEC0?0000,长度为 4 字节,IOWIN 在
0
x
F
E
C
0
?
0010
0xFEC0\ 0010
0xFEC0?0010,两者中间差了 $1$2 字节,所以填充
12
12
12 字节补上空位方便操作。
通过 IOREGSE L 选定寄存器,然后从IOWIN 中读写相应寄存器,因此也能明白下面两个读写函数:
static uint ioapicread(int reg)
{
ioapic->reg = reg;
return ioapic->data;
}
static void ioapicwrite(int reg, uint data)
{
ioapic->reg = reg;
ioapic->data = data;
}
这两个函数就是根据
i
n
d
e
x
/
d
a
t
a
index/data
index/data 来读写 IOAPIC 的寄存器。下面来看看 IOAPIC 寄存器分别有些什么意义,了解了之后自然就知道为什么要这样那样的初始化了。下面只说
x
v
6
xv6
xv6 中涉及到的寄存器,其他的有兴趣见文末链接。
IOAPIC 寄存器
ID Register
Version Register
重定向表项
IOAPIC 有 24 个管脚,每个管脚都对应着一个 64 位的重定向表项(也相当于 64 位的寄存器),保存在
0
x
10
?
0
x
3
F
0x10-0x3F
0x10?0x3F,重定向表项的格式如下所示:
这是
Z
X
_
W
I
N
G
ZX\_WING
ZX_WING 大佬在他的
I
n
t
e
r
r
u
p
t
?
i
n
?
L
i
n
u
x
Interrupt\ in\ Linux
Interrupt?in?Linux 中总结出来的,很全面也很复杂,这里有所了解就好,配合着下面的初始化代码对部分字段作相应的解释。
IOAPIC 初始化
#define IOAPIC 0xFEC00000
void ioapicinit(void)
{
int i, id, maxintr;
ioapic = (volatile struct ioapic*)IOAPIC;
maxintr = (ioapicread(REG_VER) >> 16) & 0xFF;
id = ioapicread(REG_ID) >> 24;
if(id != ioapicid)
cprintf("ioapicinit: id isn't equal to ioapicid; not a MP\n");
for(i = 0; i <= maxintr; i++){
ioapicwrite(REG_TABLE+2*i, INT_DISABLED | (T_IRQ0 + i));
ioapicwrite(REG_TABLE+2*i+1, 0);
}
}
宏定义
I
O
A
P
I
C
IOAPIC
IOAPIC 是个地址值,这个地址就是 IOREGSEL 寄存器在内存中映射的位置,通过
i
n
d
e
x
/
d
a
t
a
index/data
index/data 方式读取 ID ,支持的中断数等信息。
I
O
A
P
I
C
?
I
D
IOAPIC\ ID
IOAPIC?ID 在
M
P
?
C
o
n
f
i
g
u
r
a
t
i
o
n
?
T
a
b
l
e
?
E
n
t
r
y
MP\ Configuration\ Table\ Entry
MP?Configuration?Table?Entry 中有记录,关于
M
P
?
T
a
b
l
e
MP\ Table
MP?Table 我们在 @@@@@@@@@@@ 一文中提到过,简单来说,
M
P
?
T
a
b
l
e
MP\ Table
MP?Table 有各种表项,记录了多处理器下的一些配置信息,计算机启动的时候可以从中获取有用信息。多处理器下的计算机启动@@@@一文只说明了处理器类型的表项,有多少个处理器类型的表项表示有多少个处理器。IOAPIC 同样的道理,然后每个
I
O
A
P
I
C
IOAPIC
IOAPIC 类型的表项中有其
I
D
ID
ID 记录。关于
M
P
?
T
a
b
l
e
MP\ Table
MP?Table 咱们就点到为止,有兴趣的可以去公众号后台获取
M
P
?
S
p
e
c
MP\ Spec
MP?Spec 的资料文档,有详细的解释。
接着就是一个
f
o
r
for
for 循环,来初始化 24 个重定向表项。来看看设置了哪些内容:
-
T
_
I
R
Q
0
+
i
T\_IRQ0+i
T_IRQ0+i,这个表示中断向量号,一个中断向量号就表示一个中断。表明此重定向表项处理
T
_
I
R
Q
0
+
i
T\_IRQ0+i
T_IRQ0+i 这个中断。
-
#
d
e
f
i
n
e
??
I
N
T
_
D
I
S
A
B
L
E
D
??
0
x
00010000
\#define\ \ INT\_DISABLED\ \ 0x00010000
#define??INT_DISABLED??0x00010000,设置此位来屏蔽与该重定向表项相关的中断,也就是说当硬件外设向
IOAPIC 发送中断信号时,IOAPIC 直接屏蔽忽略。 - 设置
b
i
t
13
bit13
bit13 ,
b
i
t
15
bit15
bit15 为 0, 分别表示管脚高电平有效,触发模式为边沿触发,这是数字逻辑中的概念,应该都知道吧,不知的话需要去补补了,基本东西还是需要知道。
- 设置
b
i
t
11
bit11
bit11 为 0 表示
P
h
y
s
i
c
a
l
?
M
o
d
e
Physical\ Mode
Physical?Mode,设置高 8 位的
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 为 0。在
P
h
y
s
i
c
a
l
?
M
o
d
e
Physical\ Mode
Physical?Mode 模式下,
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 字段就表示
L
A
P
I
C
?
I
D
LAPIC\ ID
LAPIC?ID,
L
A
P
I
C
?
I
D
LAPIC\ ID
LAPIC?ID 又唯一标识一个
C
P
U
CPU
CPU,所以
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 就表示此中断会路由到该
C
P
U
CPU
CPU,交由该
C
P
U
CPU
CPU 来处理。
因此这里初始化将所有重定向表项设置为边沿触发,高电平有效,所有中断路由到
C
P
U
0
CPU0
CPU0,但又将所有中断屏蔽的状态。
x
v
6
xv6
xv6 的注释描述的是不会将中断路由到任何处理器,这里我认为是有误的,虽然屏蔽了所有中断,但是根据
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 字段来看应该是路由到
C
P
U
0
CPU0
CPU0 的,若我理解错还请批评指针。
另外为什么要加上一个
T
_
I
R
Q
0
T\_IRQ0
T_IRQ0 呢,
T
_
I
R
Q
0
T\_IRQ0
T_IRQ0 是个宏,值为 32,前 32 个中断向量号分配给了一些异常或者保留,后面的中断向量号 32~255 才是一些外部中断或者 INT n 指令可以使用的。
上述 IOAPIC 初始化的时候直接将管脚对应的中断全都给屏蔽了,那总得有开启的时候吧,不然无法工作也就无意义了,“开启”函数如下所示:
void ioapicenable(int irq, int cpunum)
{
ioapicwrite(REG_TABLE+2*irq, T_IRQ0 + irq);
ioapicwrite(REG_TABLE+2*irq+1, cpunum << 24);
}
T
_
I
R
Q
0
+
i
r
q
T\_IRQ0 + irq
T_IRQ0+irq 为中断向量号,填写到低 8 位 vector 字段,表示此重定向表项处理该中断
c
p
u
n
u
m
cpunum
cpunum 为 CPU 的编号,
m
p
.
c
mp.c
mp.c 文件中定义了关于
C
P
U
CPU
CPU 的全局数组,存放着所有
C
P
U
CPU
CPU 的信息。
x
v
6
xv6
xv6 里面,这个数组的索引是就是
c
p
u
n
u
m
cpunum
cpunum 也是
L
A
P
I
C
?
I
D
LAPIC\ ID
LAPIC?ID,可以来唯一标识一个
C
P
U
CPU
CPU。初始化的时候
D
e
s
t
i
n
a
t
i
o
n
?
M
o
d
e
Destination\ Mode
Destination?Mode 为 0,调用此函数没有改变该位,所以还是 0,为物理模式,所以将
c
p
u
n
u
m
cpunum
cpunum 写入
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 字段表示将中断路由到该
C
P
U
CPU
CPU。
来做个简单测试,在磁盘相关代码文件
i
d
e
.
c
ide.c
ide.c 中函数
i
d
e
i
n
i
t
(
)
ideinit()
ideinit() 调用了
i
o
a
p
i
c
e
n
a
b
l
e
(
)
ioapicenable()
ioapicenable():
ioapicenable(IRQ_IDE, ncpu - 1);
根据上述讲的,这说明使用最后一个
C
P
U
CPU
CPU 来处理磁盘中断,下面我们来验证,验证方式很简单,在中断处理程序当中打印
C
P
U
CPU
CPU 编号就行:
首先在
M
a
k
e
f
i
l
e
Makefile
Makefile 中将
C
P
U
CPU
CPU 数量设为多个处理器,我设置的是 4:
ifndef CPUS
CPUS := 4
endif
接着在
t
r
a
p
.
c
trap.c
trap.c 文件中添加
p
r
i
n
t
f
printf
printf 语句:
case T_IRQ0 + IRQ_IDE:
ideintr();
lapiceoi();
cprintf("ide %d\n", cpuid());
break;
这个函数我们后面会讲到,这里提前看一看,有注释应该还是很好理解的,来看看结果:
C
P
U
CPU
CPU 的数量为 4,处理磁盘中断的
C
P
U
CPU
CPU 编号为 3,符合预期,
I
O
A
P
I
C
IOAPIC
IOAPIC 的初始化就说到这里,下面来看
L
A
P
I
C
LAPIC
LAPIC 的初始化。
LAPIC
LAPIC 要比 IOAPIC 复杂的多,放张总图:
x
v
6
xv6
xv6 不会涉及这么复杂,其主要功能是接收 IOAPIC 发来的中断消息然后交由
C
P
U
CPU
CPU 处理,再者就是自身也能作为中断源产生中断发送给自身或其他
C
P
U
CPU
CPU。同样的初始化 LAPIC 就是设置相关寄存器,但是 LAPIC 的寄存器实在太多了,本文只是说明 xv6 涉及到的寄存器,其他的可以参考前文@@@@@@@@@@@,或者文末的链接。
LAPIC 的寄存器在内存中都有映射,起始地址一般默认为
0
x
F
E
E
0
?
0000
0xFEE0\ 0000
0xFEE0?0000,但这个地址不是自己设置使用的,起始地址在
M
P
?
T
a
b
l
e
?
H
e
a
d
e
r
MP\ Table\ Header
MP?Table?Header 中可以获取,详见文末链接@@@@@@@@@@,所以可以如下定义和获取
l
a
p
i
c
lapic
lapic 地址
volatile uint *lapic;
lapic = (uint*)conf->lapicaddr;
l
a
p
i
c
lapic
lapic 也可以看作是
u
i
n
t
uint
uint 型的数组,一个元素 4 字节,所以计算各个寄存器的索引的时候要在偏移量的基础上除以 4。举个例子,ID 寄存器相对
l
a
p
i
c
lapic
lapic 基地址偏移量为
0
x
20
0x20
0x20,那么 ID 寄存器在
l
a
p
i
c
lapic
lapic 数组里面的索引就该为 0x20/4。各个寄存器的偏移量见文末链接(说了太多次,希望不要觉得太啰嗦,因为内容实在太多,又想说明白那就只能这样放链接)
因为是 LAPIC 的寄存器是内存映射,所以设置寄存器就是直接读写相应内存,因此读写寄存器的函实现是很简单的:
static void lapicw(int index, int value)
{
lapic[index] = value;
lapic[ID];
}
这里看着是写内存,但是实际上这部分地址已经分配给了 LAPIC ,对硬件的写操作一般要停下等一会儿待写操作完成,可以去看看磁盘键盘等硬件初始配置的时候都有类似的等待操作,这里直接采用读数据的方式来等待写操作完成。
LAPIC 初始化
有了读写 LAPIC 寄存器的函数,接着就来看看 LAPIC 如何初始化的,初始化函数为
l
a
p
i
c
i
n
i
t
(
)
lapicinit()
lapicinit(),我们分开来看:
lapicw(SVR, ENABLE | (T_IRQ0 + IRQ_SPURIOUS));
#define SVR (0x00F0/4)
#define ENABLE 0x00000100
SVR 伪中断寄存器,
C
P
U
CPU
CPU 每响应一次
I
N
T
R
INTR
INTR(可屏蔽中断),就会连续执行两个
I
N
T
A
INTA
INTA 周期。在
M
P
?
S
p
e
c
MP\ Spec
MP?Spec 中有描述,当一个中断在第一个
I
N
T
A
INTA
INTA 周期后,第二个
I
N
T
A
INTA
INTA 周期前变为无效,则为伪中断,也就是说伪中断就是中断引脚没有维持足够的有效电平而产生的。这主要涉及到电气方面的东西,我们了解就好。
S
V
R
SVR
SVR 中的字段还有其他作用,
b
i
t
?
8
bit\ 8
bit?8 置 1 表示使能 LAPIC ,LAPIC 需要在使能状态下工作。
lapicw(TDCR, X1);
lapicw(TIMER, PERIODIC | (T_IRQ0 + IRQ_TIMER));
lapicw(TICR, 10000000);
#define TICR (0x0380/4)
#define TDCR (0x03E0/4)
#define TIMER (0x0320/4)
#define X1 0x0000000B
#define PERIODIC 0x00020000
LAPIC 自带可编程定时器,可以用这个定时器来作为时钟,触发时钟中断。这需要
T
D
C
R
(
T
h
e
?
D
i
v
i
d
e
?
C
o
n
f
i
g
u
r
a
t
i
o
n
?
R
e
g
i
s
t
e
r
)
TDCR(The\ Divide\ Configuration\ Register)
TDCR(The?Divide?Configuration?Register)、
T
I
C
R
(
T
h
e
?
I
n
i
t
i
a
l
?
C
o
u
n
t
?
R
e
g
i
s
t
e
r
)
TICR(The\ Initial-Count\ Register)
TICR(The?Initial?Count?Register)、以及
L
V
T
?
T
i
m
e
r
?
R
e
g
i
s
t
e
r
LVT\ Timer\ Register
LVT?Timer?Register 配合使用,其实还有一个
C
u
r
r
e
n
t
?
c
o
u
n
t
?
R
e
g
i
s
t
e
r
Current-count\ Register
Current?count?Register,
x
v
6
xv6
xv6 没有使用,这些寄存器的具体配置如上代码所示,解释如下:
这几个寄存器表示
L
V
T
(
L
o
c
a
l
?
V
e
c
t
o
r
?
T
a
b
l
e
)
LVT(Local\ Vector\ Table)
LVT(Local?Vector?Table) 本地中断,LAPIC 除了可以接收 IOAPIC 发来的中断之外,自己也可以产生中断,就是上述列出来的这几种。
从上图可以看出
T
i
m
e
r
Timer
Timer 寄存器
b
i
t
17
,
b
i
t
18
bit17,bit18
bit17,bit18 设置
T
i
m
e
r
?
M
o
d
e
Timer\ Mode
Timer?Mode,
x
v
6
xv6
xv6 设置为
01
01
01 即
P
e
r
i
o
d
i
c
Periodic
Periodic 模式,从名字就可以看出这是周期性模式,周期性的从某个数递减到 0,如此循环往复。
这个数设置在
T
I
C
R
TICR
TICR 寄存器,
x
v
6
xv6
xv6 设置的值是
10000000
10000000
10000000
递减得有个频率,这个频率是系统的总线频率再分频,分频系数设置在
T
D
C
R
TDCR
TDCR 寄存器,
x
v
6
xv6
xv6 设置的是 1 分频,也就相当于没有分频,就是使用的是总线频率。
另外
T
_
I
R
Q
0
+
I
R
Q
_
T
I
M
E
R
T\_IRQ0 + IRQ\_TIMER
T_IRQ0+IRQ_TIMER 是时钟中断的向量号,设置在
T
i
m
e
r
Timer
Timer 寄存器的低 8 位。
关于时钟中断的设置就是这么多,每个
C
P
U
CPU
CPU 都有
L
A
P
I
C
LAPIC
LAPIC,所以每个
C
P
U
CPU
CPU 上都会发生时钟中断,不像其他中断,指定了一个
C
P
U
CPU
CPU 来处理。
回到 LAPIC 的初始化上面来:
lapicw(LINT0, MASKED);
lapicw(LINT1, MASKED);
L
I
N
T
0
,
L
I
N
T
1
LINT0,LINT1
LINT0,LINT1连接到了
i
8259
A
i8259A
i8259A 和
N
M
I
NMI
NMI,但实际上只连接到了
B
S
P
BSP
BSP(最先启动的
C
P
U
CPU
CPU),只有
B
S
P
BSP
BSP 能接收这两种中断。一般对于
B
S
P
BSP
BSP 如果有
P
I
C
PIC
PIC 模式(兼容
i
8259
i8259
i8259)
L
I
N
T
0
LINT0
LINT0 设置为
E
x
t
I
N
T
ExtINT
ExtINT 模式,
L
I
N
T
1
LINT1
LINT1 设置为
N
M
I
NMI
NMI 模式。如果是
A
P
AP
AP 直接设置屏蔽位将两种中断屏蔽掉。
x
v
6
xv6
xv6 简化了处理,只使用 APIC 模式,所有的 LAPIC 都将两种中断给屏蔽掉了。
if(((lapic[VER]>>16) & 0xFF) >= 4)
lapicw(PCINT, MASKED);
lapicw(ERROR, T_IRQ0 + IRQ_ERROR);
lapicw(ESR, 0);
lapicw(ESR, 0);
#define VER (0x0030/4)
#define ERROR (0x0370/4)
#define PCINT (0x0340/4)
#define ESR (0x0280/4)
Version Register ? 的
b
i
t
16
?
b
i
t
23
bit16-bit23
bit16?bit23 是
L
V
T
LVT
LVT 本地中断的表项个数,如果超过了 4 项则屏蔽性能计数溢出中断。为什么这么操作,这个中断有什么用不太清楚,这个在 intel 手册卷三有描述,看了之后还是懵懵懂懂,感觉平常不会接触,用到的少,就没深入的去啃了,所以也不能拿出来乱说,在此抱歉,有了解的大佬还请告知。
ERROR Register ?,设置这个寄存器来映射
E
R
R
O
R
ERROR
ERROR 中断,当 $APIC $检测到内部错误的时候就会触发这个中断,中断向量号是
T
_
I
R
Q
0
+
I
R
Q
_
E
R
R
O
R
T\_IRQ0 + IRQ\_ERROR
T_IRQ0+IRQ_ERROR
E
S
R
(
E
R
R
O
R
?
S
t
a
t
u
s
?
R
e
g
i
s
t
e
r
)
ESR(ERROR\ Status\ Register)
ESR(ERROR?Status?Register) 记录错误状态,初始化就是将其清零,而且需要连续写两次。
lapicw(EOI, 0);
#define EOI (0x00B0/4)
EOI (
E
n
d
?
o
f
?
I
n
t
e
r
r
u
p
t
End\ of\ Interrupt
End?of?Interrupt),中断处理完成之后要写 EOI 寄存器来显示表示中断处理已经完成。重置初始化后的值应为 0.
lapicw(ICRHI, 0);
lapicw(ICRLO, BCAST | INIT | LEVEL);
while(lapic[ICRLO] & DELIVS)
;
#define ICRHI (0x0310/4)
#define TIMER (0x0320/4)
#define INIT 0x00000500
#define STARTUP 0x00000600
#define DELIVS 0x00001000
#define ASSERT 0x00004000
#define DEASSERT 0x00000000
#define LEVEL 0x00008000
#define BCAST 0x00080000
#define BUSY 0x00001000
#define FIXED 0x00000000
ICR (
I
n
t
e
r
r
u
p
t
?
C
o
m
m
a
n
d
?
R
e
g
i
s
t
e
r
Interrupt\ Command\ Register
Interrupt?Command?Register)中断指令寄存器,当一个
C
P
U
CPU
CPU 想把中断发送给另一个
C
P
U
CPU
CPU 时,就在 ICR 中填写相应的中断向量和目标 LAPIC 标识,然后通过总线向目标 LAPIC 发送消息。因为同样是向另一个 LAPIC 发送中断消息,所以ICR 寄存器的字段和 IOAPIC 重定向表项较为相似,都有
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
,
D
e
l
i
v
e
r
y
?
M
o
d
e
,
D
e
s
t
i
n
a
t
i
o
n
?
M
o
d
e
,
L
e
v
e
l
Destination\ Field, Delivery\ Mode, Destination\ Mode, Level
Destination?Field,Delivery?Mode,Destination?Mode,Level 等等。
S
e
n
d
?
a
n
?
I
n
i
t
?
L
e
v
e
l
?
D
e
?
A
s
s
e
r
t
?
t
o
?
s
y
n
c
h
r
o
n
i
s
e
?
a
r
b
i
t
r
a
t
i
o
n
?
I
D
′
s
Send\ an\ Init\ Level\ De-Assert\ to\ synchronise\ arbitration\ ID's
Send?an?Init?Level?De?Assert?to?synchronise?arbitration?ID′s. 结合
i
n
t
e
l
intel
intel 手册,作用为将所有
C
P
U
CPU
CPU 的 APIC 的
A
r
b
?
I
D
Arb\ ID
Arb?ID 设置为初始值
A
P
I
C
?
I
D
APIC\ ID
APIC?ID。
关于 Arb ,引用
I
n
t
e
r
r
u
p
t
i
n
L
i
n
u
x
Interrupt in Linux
InterruptinLinux 中的解释:
Arb,Arbitration Register,仲裁寄存器。该寄存器用 4 个 bit 表示 0~15 共 16 个优先级(15 为最高优先级),用于确定 LAPIC 竞争 APIC BUS 的优先级。系统 RESET 后,各 LAPIC 的 Arb 被初始化为其 LAPIC ID。总线竞争时,Arb 值最大 的 LAPIC 赢得总线,同时将自身的 Arb 清零,并将其它 LAPIC 的 Arb 加一。由 此可见,Arb 仲裁是一个轮询机制。Level 触发的 INIT IPI 可以将各 LAPIC 的 Arb 同步回当前的 LAPIC ID。
lapicw(TPR, 0);
#define TPR (0x0080/4)
任务优先级寄存器,确定当前 CPU 能够处理什么优先级别的中断,CPU 只处理比 TPR 中级别更高的中断。比它低的中断暂时屏蔽掉,也就是在 IRR 中继续等到。
上述就是
x
v
6
xv6
xv6 里面对 LAPIC 的一种简单的初始化方式,其实也不简单,涉及了挺多东西。接下来应该是 CPU 来处理中断的部分,在这之前先来看看
l
a
p
i
c
.
c
lapic.c
lapic.c 里面涉及到的两个用的比较多的函数:
int lapicid(void)
{
if (!lapic)
return 0;
return lapic[ID] >> 24;
}
这个函数用来返回
L
A
P
I
C
?
I
D
LAPIC\ ID
LAPIC?ID,ID 寄存器
b
i
t
24
bit24
bit24 位后表示
L
A
P
I
C
?
I
D
LAPIC\ ID
LAPIC?ID因为
C
P
U
CPU
CPU 与 LAPIC 一一对应,所以这也相当于返回
C
P
U
?
I
D
CPU\ ID
CPU?ID,同样也是
C
P
U
CPU
CPU 数组中的索引。而前面在
I
O
A
P
I
C
IOAPIC
IOAPIC 一节中出现的
c
p
u
i
d
(
)
cpuid()
cpuid() 函数相当于就是这个函数的封装。
void lapiceoi(void)
{
if(lapic)
lapicw(EOI, 0);
}
写 EOI 表中断完成,这个函数在中断服务程序中会经常用到用到,下面再来看看 LAPIC 中两个比较重要的寄存器:
上述就是 APIC 的初始化和一些重要函数的讲解,有了这些了解之后,来总体的看一看 APIC 部分的中断过程:
- 外设触发中断,发送中断信号给
IOAPIC IOAPIC 根据
P
R
T
PRT
PRT 表将中断信号翻译成中断消息,然后发送给
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field 字段列出的
L
A
P
I
C
LAPIC
LAPICLAPIC 根据消息中的
D
e
s
t
i
n
a
t
i
o
n
?
M
o
d
e
Destination\ Mode
Destination?Mode,
D
e
s
t
i
n
a
t
i
o
n
?
F
i
e
l
d
Destination\ Field
Destination?Field,自身的寄存器 ID 来判断自己是否接收该中断消息,设置 IRR 相应的
b
i
t
bit
bit 位,不是则忽略-
C
P
U
CPU
CPU 在可以处理下一个中断时,从
IRR 中挑选优先级最大的中断,相应位置 0,ISR 相应位置 1,然后送
C
P
U
CPU
CPU 执行。 -
C
P
U
CPU
CPU 执行中断服务程序处理中断
- 中断处理完成后写
EOI 表示中断处理已经完成,写 EOI 导致 ISR 相应位置 0,对于
l
e
v
e
l
level
level 触发的中断,还会向所有的 IOAPIC 发送 EOI 消息,通知中断处理已经完成。
上述的过程只是一个很简单的大致过程,没有涉及到不可屏蔽中断,一些特殊的中断,中断嵌套等等,只是来简单认识一下 APIC 在中断时是如何工作的,接下来重点看看
C
P
U
CPU
CPU 部分对中断的处理。
CPU 部分
上述就是
A
P
I
C
APIC
APIC 的初始化部分,被
m
a
i
n
.
c
main.c
main.c 中的
m
a
i
n
(
)
main()
main() 调用,是计算机启动时环境初始化的一部分。下面来看
C
P
U
CPU
CPU 处理中断的部分。先来复习一下
C
P
U
CPU
CPU 部分大致是如何处理中断的:
-
C
P
U
CPU
CPU 收到中断控制器发来的中断向量号
- 根据中断向量号去
I
D
T
IDT
IDT 索引门描述符,根据门描述符中的段选择子去
G
D
T
GDT
GDT 中索引段描述符
- 这期间
C
P
U
CPU
CPU 会进行特权级检查,如果特权级有变化,如用户态进入内核态,压入原栈
S
S
SS
SS,
E
S
P
ESP
ESP 到内核栈,如果没有变化则不用压入。之后压入
C
S
CS
CS,
E
I
P
EIP
EIP,
E
F
L
A
G
S
EFLAGS
EFLAGS,该中断有错误码的话还需要压入错误码。
- 根据段描述符中的段基址和中断描述符中的偏移量取得中断服务程序的地址
- 执行中断服务程序,这期间会压入寄存器等资源,保存上下文
- 执行完成后恢复上下文,写
EOI? 表中断完成
所以在中断正式处理之前就压入一些寄存器,栈中情况如下:
接下来便就是去 IDT , GDT 中索引门描述符和段描述符,寻找中断服务程序,本文主要讲述中断,所以只来看看 IDT ,GDT 相关内容我在 @@@@@@@@@有所讲述,可以参考参考。
构建 IDT
IDT
(
I
n
t
e
r
r
u
p
t
?
D
e
s
c
r
i
p
t
o
r
?
T
a
b
l
e
)
(Interrupt\ Descriptor\ Table)
(Interrupt?Descriptor?Table),中断描述符表,我们得先有这么一个表,
C
P
U
CPU
CPU 才能使用中断控制器发送来的向量号去
I
D
T
IDT
IDT 中索引门描述符。
所以得构建一个 IDT? ,构建 IDT 就是构建一个个中断描述符,一般称作门描述符,IDT ? 里面可以存放几种门描述符,如调用门描述符,陷阱门描述符,任务门描述符,中断门描述符。大多数中断都使用中断门描述符,来看看中断门描述符的格式:
其实上述也可以作为陷阱门描述符,两者几乎一模一样,只有
T
Y
P
E
TYPE
TYPE 字段不一样,所以如下定义中断门/陷阱门描述符:
struct gatedesc {
uint off_15_0 : 16;
uint cs : 16;
uint args : 5;
uint rsv1 : 3;
uint type : 4;
uint s : 1;
uint dpl : 2;
uint p : 1;
uint off_31_16 : 16;
};
-
b
i
t
0
?
b
i
t
15
bit0-bit15
bit0?bit15:中断服务程序在目标代码段中的偏移量 0~15 位
-
b
i
t
16
?
b
i
t
31
bit16-bit31
bit16?bit31:中断服务程序所在段的段选择子
-
b
i
t
40
?
b
i
t
43
bit40-bit43
bit40?bit43:中断门的
T
Y
P
E
TYPE
TYPE 值为 1110,陷阱门为 1111
-
b
i
t
44
bit44
bit44:S 字段为 0 表示系统段,各种门结构都是系统段,意为这是硬件需要的结构,反之软件需要的则是非系统段,包括平常所说的数据段和代码段,这不是硬件必须的,为非系统段。
-
b
i
t
45
?
b
i
t
46
bit45-bit46
bit45?bit46:
D
P
L
(
D
e
s
c
r
i
p
t
o
r
?
P
r
i
v
i
l
e
g
e
?
L
e
v
e
l
)
DPL(Descriptor\ Privilege\ Level)
DPL(Descriptor?Privilege?Level),描述符特权级,进入中断时会用来特权级检查。
-
b
i
t
47
bit47
bit47:
P
(
P
r
e
s
e
n
t
)
P(Present)
P(Present) 该段在内存中是否存在,存在为 1,否则为 0
-
b
i
t
48
?
b
i
t
63
bit48-bit63
bit48?bit63:中断服务程序在内核代码段中的偏移量 16~31 位
从上面部分字段代表的意义可以看出,构建中断门描述符还需要中断服务程序的地址信息,所以咱们首先还得准备好各个中断服务程序,取得它们的地址信息。在
x
v
6
xv6
xv6 中?,所有的中断都有相同的入口程序,而在中断门描述符中填写的就是这个入口程序的地址。
IDT 中支持 256 个表项,支持 256 个中断,所以要有 256 个入口程序,入口程序所做的工作是类似的,所以
x
v
6
xv6
xv6 使用了
p
e
r
l
perl
perl 脚本来批量产生代码。脚本文件是
v
e
c
t
o
r
s
.
p
l
vectors.pl
vectors.pl,生成的代码如下所示:
.globl alltraps
.globl vector0 #向量号为0的入口程序
vector0:
pushl $0
pushl $0
jmp alltraps
#############################
.globl vector8
vector8:
pushl $8
jmp alltraps
##############################
.globl vectors #入口程序数组
vectors:
.long vector0
.long vector1
.long vector2
这是一段汇编代码,所有的中断入口程序都做了相同的三件事或两件事:
- 压入 0,其实这个位置是错误码的位置,有些中断会产生错误码压入栈中,所以为了统一,没有错误码的中断也压入一个东西:0
- 压入自己的中断向量号
- 跳到
a
l
l
t
r
a
p
s
alltraps
alltraps 去执行中断处理程序
第一项 压入 0 只有没有错误码产生的中断/异常才会执行,而错误码主要部分就是选择子,一般不使用。但这是
x
86
x86
x86 架构特性,有错误码的时候会自动压入,所以在
p
e
r
l
perl
perl 脚本中对有错误码的异常做了特殊处理:
if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
print " pushl \$0\n";
表示向量号为
8
,
10
?
14
,
17
8,10-14,17
8,10?14,17 号会产生错误码,不需要压入 0。
这 256 个中断入口程序地址写入一个大数组
v
e
c
t
o
r
s
vectors
vectors,所以中断门描述符要的地址信息不就来了,因此 IDT 的构建如下:
struct gatedesc idt[256];
extern uint vectors[];
void tvinit(void)
{
int i;
for(i = 0; i < 256; i++)
SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);
initlock(&tickslock, "time");
}
#define SETGATE(gate, istrap, sel, off, d) \
{ \
(gate).off_15_0 = (uint)(off) & 0xffff; \
(gate).cs = (sel); \
(gate).args = 0; \
(gate).rsv1 = 0; \
(gate).type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).s = 0; \
(gate).dpl = (d); \
(gate).p = 1; \
(gate).off_31_16 = (uint)(off) >> 16; \
}
S
E
G
G
A
T
E
SEGGATE
SEGGATE 宏就是根据信息构建一个中断描述符,应该很容易看懂。
中断服务程序属于内核程序,段选择子为内核代码段,
D
P
L
DPL
DPL 设置为 0,但是系统调用需要特殊处理,
D
P
L
DPL
DPL 字段必须设置为 3。为什么这么设置,原由与特权级检查有关:当前代码段寄存器的
R
P
L
(
R
e
q
u
e
s
t
P
r
i
v
i
l
e
g
e
L
e
v
e
l
,
请
求
特
权
级
)
RPL(Request Privilege Level,请求特权级)
RPL(RequestPrivilegeLevel,请求特权级) 为
C
P
L
(
C
u
r
r
e
n
t
?
P
r
e
v
i
l
e
g
e
?
L
e
v
e
l
,
当
前
特
权
级
)
CPL(Current\ Previlege\ Level,当前特权级)
CPL(Current?Previlege?Level,当前特权级),也就是
C
P
L
=
C
S
.
R
P
L
CPL=CS.RPL
CPL=CS.RPL。是不是很绕,没办法,事实就是这样。
作何特权级检查呢?
C
P
L
CPL
CPL 需要大于等于门描述符中选择子的
D
P
L
DPL
DPL,而对于系统调用
C
P
L
CPL
CPL 还需要小于等于门描述符的
D
P
L
DPL
DPL,不然就会触发一般保护性错异常。系统调用特权级肯定是要转移的,也就是从用户态到内核态,用户态下
C
P
L
=
3
CPL = 3
CPL=3,门描述符
D
P
L
DPL
DPL 如果还为 0 的话,那特权级检查不能通过,是要触发异常的,所以对于系统调用
D
P
L
DPL
DPL 得设置为 3。
这说的有点远了,特权级检查是个很复杂的东西,上面还没有加入
R
P
L
RPL
RPL 的检查呢。这里只是稍作了解就好,后面有机会写一篇捋一捋特权级检查,下面回到 IDT 本身上来,IDT 构建好了之后需要将其地址加载到 IDTR 寄存器,如此
C
P
U
CPU
CPU 才晓得去哪儿找 IDT 。
void idtinit(void)
{
lidt(idt, sizeof(idt));
}
static inline void lidt(struct gatedesc *p, int size)
{
volatile ushort pd[3];
pd[0] = size-1;
pd[1] = (uint)p;
pd[2] = (uint)p >> 16;
asm volatile("lidt (%0)" : : "r" (pd));
}
IDTR 寄存器有 48 位
-
b
i
t
0
?
b
i
t
15
bit0-bit15
bit0?bit15 表示
IDT 的界限,也就是这个表有好大,表示的最大范围为
0
x
F
F
F
F
0xFFFF
0xFFFF,也就是
64
K
B
64KB
64KB,一个门描述符 8 字节,所以描述符最多
64
K
B
/
8
B
=
8192
64KB/8B = 8192
64KB/8B=8192,但是处理器只支持 256 个中断,也就是 256 个门描述符。 -
b
i
t
16
?
b
i
t
48
bit16-bit48
bit16?bit48 表示
IDT 基地址
上述代码中数组
p
d
pd
pd 就是这 48 位数据,先构造这个数据,然后使用内联汇编,指令
l
i
d
t
lidt
lidt 将其加载到 IDTR 寄存器,关于内联汇编不多说,可以参考我前面的文章:@@@@@@@@
中断服务程序
I
D
T
IDT
IDT 准备好之后,这一小节就正式来看中断服务程序的流程,我将其分为三个阶段:中断入口,中断处理,中断退出,咱们一个个来看:
中断入口程序
中断入口程序主要是保存中断上下文,
v
e
c
t
o
r
s
vectors
vectors 数组中记录的入口程序只能算是一部分,这一部分做了三件事:压入 0/错误码,压入向量号,跳到
a
l
l
t
r
a
p
s
alltraps
alltraps。
所以现阶段栈中情况如下:
紧接着程序跳到了
a
l
l
t
r
a
p
s
alltraps
alltraps,来看看这是个什么玩意儿:
.globl alltraps
alltraps:
# Build trap frame. 构建中断栈帧
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
# Set up data segments. 设置数据段为内核数据段
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
# Call trap(tf), where tf=%esp 调用trap.c()
pushl %esp
call trap
addl $4, %esp
可以看出
a
l
l
t
r
a
p
s
alltraps
alltraps 也主要干了三件事:
- 建立栈帧,保存上下文
- 设置数据段寄存器为内核数据段
- 传参调用
t
r
a
p
.
c
(
)
trap.c()
trap.c() 中断处理程序
1、建立栈帧,保存上下文
建立栈帧保存上下文就是将各类寄存器资源压栈保存在栈中,
x
v
6
xv6
xv6 直接暴力地将所有的寄存器直接压进去。先是压入各段寄存器,再
p
u
s
h
a
l
pushal
pushal 压入所有的通用寄存器,顺序为
e
a
x
,
e
c
x
,
e
d
x
,
e
b
x
,
e
s
p
,
e
b
p
,
e
s
i
,
e
d
i
eax, ecx, edx, ebx, esp, ebp, esi, edi
eax,ecx,edx,ebx,esp,ebp,esi,edi。
所以现下栈中的情况为:
所以如此定义栈帧:
struct trapframe {
uint edi;
uint esi;
uint ebp;
uint oesp;
uint ebx;
uint edx;
uint ecx;
uint eax;
ushort gs;
ushort padding1;
ushort fs;
ushort padding2;
ushort es;
ushort padding3;
ushort ds;
ushort padding4;
uint trapno;
uint err;
uint eip;
ushort cs;
ushort padding5;
uint eflags;
uint esp;
ushort ss;
ushort padding6;
};
可以看出定义的中断栈帧结构体与前面的操作是一一对应的,说明两点:
- 段寄存器只有 16 位 2 字节,压栈段寄存器时用的
p
u
s
h
l
pushl
pushl,压入了一个双字 4 字节,所以需要
s
h
o
r
t
short
short 类型的来填充 2 字节。也可以直接将段寄存器定义为
u
i
n
t
uint
uint 类型的,省去定义填充变量。
-
p
u
s
h
a
l
pushal
pushal 时压入通用寄存器,这些寄存器加上进入中断时
C
P
U
CPU
CPU 自动压入的值就是中断发生前一刻进程的上下文。这里
p
u
s
h
a
l
pushal
pushal 压入的
E
S
P
ESP
ESP 后面注释写着无用忽略,为什呢?买个关子,后面和栈的问题一起说。
2、设置数据段寄存器为内核数据段
在根据向量号索引门描述符的时候已经进行了特权级检查,将门描述符中的段选择子——内核代码段选择子加载到了 CS,这里就只需要设置数据段寄存器为内核数据段。附加段,附加的数据段,通常与数据段进行一样的设置,在串操作指令中,将附加段作为目的操作数的存放区域,详见前文内联汇编
3、调用中断处理程序
p
u
s
h
push
push 之后
c
a
l
l
call
call,标准的函数调用方式,先
p
u
s
h
push
push 参数,再
c
a
l
l
call
call 调用函数。
p
u
s
h
?
%
e
s
p
push\ \%esp
push?%esp,此时的 esp 是中断栈帧的栈顶元素的地址,也就是说传递的参数是中断栈帧的首地址。随后
c
a
l
l
?
t
r
a
p
call\ trap
call?trap 调用中断处理程序,压入返回地址(
c
a
l
l
call
call 指令后面那条指令的地址,也就是 $addl\ $4, %esp$ 语句的地址),之后跳转到
t
r
a
p
(
)
trap()
trap() 执行程序。
此时栈中情况:
中断处理程序
上述操作已经将中断处理程序
t
r
a
p
(
s
t
r
u
c
t
?
?
t
r
a
p
f
r
a
m
e
)
trap(struct\ *trapframe)
trap(struct??trapframe) 需要的参数中断栈帧
t
r
a
p
f
r
a
m
e
trapframe
trapframe 的地址压入栈中。其实
t
r
a
p
(
)
trap()
trap() 也像是中断服务程序的入口,整个程序就是由许多条件语句组成,根据
t
r
a
p
f
r
a
m
e
trapframe
trapframe 的向量号去执行不同分支中的中断处理程序,来随便看几个:
if(tf->trapno == T_SYSCALL){
if(myproc()->killed)
exit();
myproc()->tf = tf;
syscall();
if(myproc()->killed)
exit();
return;
}
如果向量号表示这是一个系统调用,则进行系统调用,这部分放在后面文章讲解。
switch(tf->trapno){
case T_IRQ0 + IRQ_TIMER:
if(cpuid() == 0){
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
lapiceoi();
break;
case T_IRQ0 + IRQ_IDE:
ideintr();
lapiceoi();
break;
如果是时钟中断,并且是
C
P
U
0
CPU0
CPU0 发出的时钟中断,就将滴答数
t
i
c
k
s
ticks
ticks 加 1。每个
C
P
U
CPU
CPU 都有自己的 LAPIC ,也就都有自己的 APIC Timer ,都能够触发时钟中断。
t
i
c
k
s
ticks
ticks 记录系统从开始到现在的滴答数,作为系统的时间,发生一次时钟中断其数值就加 1,但是能修改
t
i
c
k
s
ticks
ticks 的应该只能有一个 CPU,不然如果所有的
C
P
U
CPU
CPU 都能修改
t
i
c
k
s
ticks
ticks 的值的话,那岂不是乱套了?所以这里就选择
C
P
U
0
CPU0
CPU0 也是
B
S
P
BSP
BSP 来修改
t
i
c
k
s
ticks
ticks 的值。处理完之后写 EOI 表时钟中断完成。
如果是磁盘发出的中断,就调用磁盘中断处理程序,也是磁盘驱动程序的主体,详见前文带你了解磁盘驱动。处理完之后就写 EOI ? 表中断完成。
其他的中断都是这样处理,就不一一举例说明了,其中有一些中断还没有讲到,但所有中断的处理都是如此,根据向量号调用不同的中断处理程序,处理完之后写 EOI 表中断完成。
中断退出程序
执行完
t
r
a
p
(
)
trap()
trap() 函数之后,回到汇编程序
t
r
a
p
a
s
m
.
S
trapasm.S
trapasm.S:
# Call trap(tf), where tf=%esp
pushl %esp
call trap
addl $4, %esp
# Return falls through to trapret...
.globl trapret #中断返回退出
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
中断退出程序基本上就是中断入口程序的逆操作。
首先从
t
r
a
p
(
)
trap()
trap() 返回之后清理参数占用的栈空间,将 ESP 上移 4 字节。一般系统的源码就是汇编和 C 程序,所以使用
c
d
e
c
l
cdecl
cdecl 调用约定,该约定规定了参数从右往左入栈,EAX,ECX,EDX 由调用者保存,也是调用者来清理栈空间等等。而清理栈空间呢?其实就是为了栈里面的数据正确,显然要当前栈顶指针需要向上移动 4 字节,后面的操作
p
o
p
a
l
popal
popal 才正确。
清理了栈空间之后弹出各个寄存器,到错误码向量号的时候直接将 ESP 上移 8 字节跳过。
栈中变化情况如下:
这里说明两点:
-
p
o
p
pop
pop 出栈操作并不会实际清理栈空间的内容,只是
E
S
P
ESP
ESP 指针和弹出目的地寄存器会有相应变化,栈里面的内容不会变化。
- 返回地址什么时候跳过的?一般情况下
c
a
l
l
call
call 与
r
e
t
ret
ret 是一对儿,
c
a
l
l
call
call 压入返回地址,
r
e
t
ret
ret 弹出返回地址,可是没看到
r
e
t
ret
ret 啊?这里是汇编和
C
C
C 语言混合编程,将
C
C
C 代码
t
r
a
p
.
c
trap.c
trap.c 编译之后就有
r
e
t
ret
ret 了,所以弹出返回地址就发生在
t
r
a
p
(
)
trap()
trap() 执行完之后。
现在
E
S
P
ESP
ESP 指向的是
E
I
P
_
O
L
D
EIP\_OLD
EIP_OLD,该执行
i
r
e
t
iret
iret 了,
i
r
e
t
iret
iret 时先检查是否进行了特权级转移,如果没有特权级转移,那么就要弹出 EIP,CS 和 EFALGS ,如果有特权级转移则还要弹出 ESP,SS 。
原任务的所有状态都恢复了原样,则中断结束,继续原先的任务。
中断的总体过程大致就是这样,不只是
x
v
6
xv6
xv6 如此,所有基于
x
86
x86
x86 架构的系统都有类似的过程,只不过复杂的操作对中断的处理有着更微妙的操作,但总体上看大致过程就是如此。
下面来看一看过程图:
这主要是定位中断服务程序的图,至于实际处理中断的过程图就不画了,把握上面的栈的变化就行了,而栈的变化情况上述的图应该描述的很清楚了,所以这里就不再赘述,说起栈,关于栈上述我们还遗留了一些问题,在这儿解答:
栈的问题
最后再来聊一聊栈的问题,栈一直是一个很困惑的问题,我一直认为,操作系统能把栈捋清楚那基本就没什么问题了。在进入中断的时候,如果特权级发生变化,会先将 SS,ESP 先压入内核栈,再压入 CS,EIP,EFLAGS 。
这句话看着没什么问题,但有没有想过这个问题:怎么找到内核栈的?切换到内核栈之后,ESP 已经指向内核栈,但是我们压入的 ESP 应该是切换栈之前的旧栈栈顶值,所以怎么得到旧栈的值再压入?再者
i
r
e
t
iret
iret 时如果按栈中的寄存器顺序只是简单的先
p
o
p
l
?
%
e
s
p
popl\ \%esp
popl?%esp,再
p
o
p
l
?
%
S
S
popl\ \%SS
popl?%SS 那岂不是又乱套了?
首先怎么切换到内核栈的这个问题,硬件架构提供了一套方法。有个寄存器叫做 TR 寄存器,TR 寄存器存放着 TSS 段选择子,根据 TSS 段选择子去 GDT 中索引 TSS 段描述符,从中获取 TSS 。
那说了半天 TSS 是啥?TSS (
T
a
s
k
?
S
t
a
t
e
?
S
e
g
m
e
n
t
Task\ State\ Segment
Task?State?Segment),任务状态段,它是硬件支持的系统数据结构,各级(包括内核)栈的 SS 和 ESP 。所以当特权级变化的时候就会从这里获取内核栈的 SS 和 ESP 。这个 TSS 这里我们只是简介,TSS 什么样子的,怎么初始化,还有些什么用处,它的功能都用到了?这些三言两语说不完,也不是本文重点,后面进程的时候会再次讲述。
接着第二个问题,切换到新栈怎么压入旧栈信息,其实这个问题很简单,我先把旧栈信息保存到一个地方,换栈之后再压入不就行了。关于
i
r
e
t
iret
iret 时弹出栈中信息是一个道理,查看
i
n
t
e
l
intel
intel 手册第二卷可以找到答案,的确也是这样处理的,手册中的伪码明显表示了有
t
e
m
p
temp
temp 来作为中转站。但这个
t
e
m
p
temp
temp 具体是个啥就不知道了,手册中也没明确说明,可能是另外的寄存器?这个不得而知,也不是重点没必要研究那么深入。
本文中断关于栈还有一个地方值得聊聊,嗯其实也没多大聊的,就是解释一句。建立栈帧的时候
p
u
s
h
a
l
,
p
o
p
a
l
pushal,popal
pushal,popal 的问题,这个是用来压入和弹出那 8 个通用寄存器的,还记得中断栈帧结构体中关于 ESP 的注释吗?写的是
u
s
e
l
e
s
s
?
i
g
n
o
r
e
useless\ ignore
useless?ignore,意思是无用忽略,这是为啥?
这得从
p
u
s
h
a
l
pushal
pushal 说起,
p
u
s
h
a
l
pushal
pushal 中压入 ESP 的时候压入的是 执行到
p
u
s
h
l
?
e
s
p
pushl\ esp
pushl?esp 的值吗?非也,压入的是 执行
p
u
s
h
a
l
pushal
pushal 前的栈顶值,在执行
p
u
s
h
a
l
pushal
pushal 之前先将 ESP 的值保存到
t
e
m
p
temp
temp,当压入 ESP 的时候执行的时
p
u
s
h
?
t
e
m
p
push\ temp
push?temp。
所以
p
o
p
a
l
popal
popal 执行到弹出
t
e
m
p
temp
temp 的时候,就不能将其中的值弹入 ESP ,而是直接将 ESP 的值加 4 跳过
t
e
m
p
temp
temp。因为将
t
e
m
p
temp
temp 弹入 ESP 的话等于换了一个栈了,本来只该跳 4 字节的,结果跳过了很多字节,那明显就不对了嘛。
可以来张图看看,红线叉叉表示出错:
关于
p
u
s
h
a
l
,
p
o
p
a
l
pushal,popal
pushal,popal 的伪码如下:
中断这一块关于栈方面的问题就是这么多吧,发生中断时有特权级变化就换栈,内核栈地址去 TSS 中找,中断完成后将所有的寄存器信息复原,其中就有 刚进入中断时压入的 SS ESP (有特权级变化的时候),栈也就恢复到了用户态下的栈。当然如果发生中断时就在内核态,那栈就不用变换,当然这只是
x
v
6
xv6
xv6 的处理方式,其他系统可能不同,但总的来说中断的处理过程就是这么一个过程。
当然这只是一个普通外设触发的中断,一些特殊中断,中断嵌套开关中断的内容都没有讲述,中断是个很大的概念,内容也很庞杂,本文利用
x
v
6
xv6
xv6 将一个普通外设触发的中断的处理机制说明的应该还是很清楚的,好啦本文就到这里,有什么错误还请批评指正,也欢迎大家来同我交流讨论学习进步。
https://wiki.osdev.org/APIC#Local_APIC_configuration
https://wiki.osdev.org/IOAPIC
http://blog.chinaunix.net/uid-20499746-id-1663122.html
|