共计 7395 个字符,预计需要花费 19 分钟才能阅读完成。
系列目录
- 序篇
- 筹备工作
- BIOS 启动到实模式
- GDT 与保护模式
- 虚拟内存初探
- 加载并进入 kernel
- 显示与打印
- 全局描述符表 GDT
- 中断解决
- 虚拟内存欠缺
- 实现堆和 malloc
- 创立第一个内核线程
- 多线程运行与切换
- 锁与多线程同步
- 过程的实现
- 进入用户态
- 一个简略的文件系统
- 加载可执行程序
- 零碎调用的实现
- 键盘驱动
- 运行 shell
中断
中断在 CPU 中扮演着十分重要的角色,对硬件的响应,工作的切换,异样的解决都离不开中断。它既是驱动所有的源能源,又是给咱们带来各种苦楚的万恶之源。
中断相干的问题将会贯通整个 kernel 的开发,它的难点就在于它对代码执行流的打乱,以及它的不可预知性。本篇只是初步搭建起中断解决的框架,前面它会始终如影随形,同时也是考验 kernel 设计实现的试金石。
概念筹备
常常有一些对于中断、异样,硬中断,软中断等文字上的概念混同,而且中英文在这些术语的应用也有些不对立。为了了解上的对立,咱们在前面的术语应用上做一个申明:
- 中断,这个词用作总体概念,即它包含各种类型的中断和异样;
而后在中断这个总概念下,做如下分类:
- 异样(
exception
):外部中断,它是 CPU 外部执行时遇到的谬误,在英文表述上它有exception
,fault
,trap
等几类,咱们个别都统称为exception
;此类问题个别是不可屏蔽,必须被解决的; - 硬中断(
interrupt
):内部中断,个别就是其它硬件设施发来的,例如时钟,硬盘,键盘,网卡等,它们能够被屏蔽; - 软中断(
soft int
):严格来说这不是中断,因为这是由int
指令被动触发的,最罕用的就是零碎调用,这是用户被动申请进入 kernel 态的形式;它的解决机制和其它中断是一样的,所以也归在中断里;
前面咱们就用英文单词 exception
来指第一类,即 CPU 外部异样和谬误,这一点没有歧义;而 interrupt
这个单词用来专指第二类,即硬中断,这也是 Intel 文档上的原始用法;至于第三类,能够先疏忽,因为目前咱们还不须要探讨它;
至于 中断 这个中文词,咱们用来指代包含上述的所有类型,是一个大概念,留神咱们不将它与单词 interrupt
等同。
留神这纯正是我的集体用法和规定,只是为了不便前面术语的表述和了解上的对立。
中断表述符表
之所以 中断 这个词会引起歧义,我想可能是因为所有以上这些货色的处理函数都放在了 中断描述符表 IDT(Interrupt Descriptor Table
)里治理,导致如同 中断 超出了 interrupt
这个词自身的领域,把 exception
也给囊括了进来。这也是为什么我想用中文词 中断 来示意总体概念,而用英文的 interrupt
和 exception
示意它上面的两个子概念。
IDT 表项
回到中断描述符表 IDT
,它的次要作用就是定义了各种中断 handler 函数,它的每个 entry 的构造定义如下:
struct idt_entry_struct {
// the lower 16 bits of the handler address
uint16 handler_addr_low;
// kernel segment selector
uint16 sel;
// this must always be zero
uint8 always0;
// attribute flags
uint8 attrs;
// The upper 16 bits of the handler address
uint16 handler_addr_high;
} __attribute__((packed));
typedef struct idt_entry_struct idt_entry_t;
代码链接在 src/interrupt/interrupt.h,对于 IDT 的文档能够参考这里。
具体每个字段的含意请参考以上文档,这里细讲一下其中两个比拟重要的字段:
sel
:即selector
,这里规定了这个中断处理函数所在的segment
。进入中断解决后,CPU 的code
段寄存器即cs
就被替换为该值,所以这个 selector 必须指向kernel
的code segment
;-
DPL
:这是attrs
字段中的 2 个 bit,它规定了可能调用,或者说进入这个 handler 所须要的 CPU 的最低特权级;对咱们来说必须将它置为特权级 3 即用户级,否则在用户态下就无奈进入中断了;
当然,下面的 IDT entry 里咱们还缺了一个最重要的局部,就是中断处理函数的地址,这个前面再讲。
构建 IDT
而后就能够定义 IDT 构造,代码在 src/interrupt/interrupt.c:
static idt_entry_t idt_entries[256];
这里预留了 256 个 entry,曾经入不敷出满足咱们的需要。
这其中前 0 ~ 31 项是留给 exception
的。从第 32 项开始,用作 interrupt
处理函数。每一个中断都会有一个中断号,对应的就是 IDT 表中的第几项,CPU 就是依据此找到中断 handler 并跳转过来。
其中 exception
是以下这些,我间接从 wiki 上拿的图:
其中第 14 个 page faualt
,即缺页异样,咱们在下一篇虚拟内存欠缺中会重点解决。其它 exception
咱们目前无需关注,因为它们失常状况下不应该呈现。
IDT 从第 32 项开始就是给 interrupt
用的,例如第 32 项就是时钟(timer
)interrupt
用的。
中断处理函数
咱们回到下面提到的中断处理函数,或者叫中断 handler
,它的地址被写在了下面 IDT 的每一个 entry 里。目前它们还不存在,所以咱们须要定义这些函数。
每个中断的 handler 当然都不一样,然而进入以及来到这些 handler 的前后都会有一些共性的工作须要做,那就是保留以及复原中断产生前的现场,或者叫上下文(context
),这次要包含各种 register
,它们将会被保留在 stack
中。因而中断的处理过程是相似这样的:
save_context();
handler();
restore_context();
值得注意的是,context
的保留和复原工作是由 CPU 和咱们共同完成的,即 CPU 会主动压入一部分 register 到 stack 中,咱们则依据须要也压入一部分 register 和其它信息到 stack 中。这两局部内容独特组成了中断的 context
。
中断 CPU 压栈
首先来看 CPU 主动压入 stack
的寄存器:
这里有两种状况:
- 如果从 kernel 态进入中断的,则只会压入左图的三个值;
- 如果是从用户态进入的中断,那么 CPU 会压入右图的五个值;
两者的区别就是顶部的 user ss 和 user esp。
咱们能够看一下这里的外在逻辑:CPU 做的事件的目标是很明确的,它保留的是指令 执行流 的上下文状态,这里蕴含了两个外围因素:
- 中断产生前的
instruction
地位(cs
和eip
提供); - 中断产生前的
stack
地位(ss
和esp
提供);
但为什么只有在产生特权级转换的时候(用户态进入 kernel 态)才须要压入以后 ss
和 esp
?因为用户态代码执行和 kernel
态代码是在不同的 stack
里进行的,从用户态进入中断解决,须要转换到 kernel 的 stack 中;等中断解决完结后再回到用户的 stack 里,所以须要将用户的 stack 信息保留下来。
下图展现了在用户态下产生中断时,stack
的跳转:
而如果中断产生在 kernel 态,那么状况会变得简略很多,因为本来就在 kernel 的 stack 中,中断解决依然是在同一个 stack 中执行,所以它的状况有点像是一次一般的函数调用(当然略有不同):
咱们看到 CPU 只负责保留了 instruction
和 stack
相干的寄存器,却没有保留 data 相干的寄存器,这次要包含了几个通用寄存器 eax
,ecx
,edx
,ebx
,esi
,edi
以及 ebp
,当然还有几个 data 段寄存器 ds
,es
,fs
,gs
等。
为什么 CPU 不论这些寄存器呢?其实我也不太明确,只能说这是 CPU 体系架构设计决定的。我集体的了解是,CPU 是一个指令执行者,它只关怀指令的执行流,这包含了 instruction
和 stack
这两个外围因素;至于 data,则应交由下层逻辑,也就是代码自身的逻辑来负责管理。这外面的设计理念,实际上是要对硬件和软件各自负责的逻辑做一个切分,实际上这很难界定,也能够了解为是历史遗留,从一开始就这么定下了。
中断 handler
扯远了,咱们回到中断 context
保留的问题。既然 CPU 没有保留 data 相干的寄存器,那就由咱们本人来保留。
咱们自顶向下,来看中断 handler 的代码。首先,每一个中断显然都有它本人的中断 handler,或者叫 isr (interrupt service routine)
:
isr0
isr1
isr2
...
这里每一个 isr*
有一个通用的构造,这里用了 asm 里的 macro 语法来定义:
; exceptions with error code pushed by CPU
%macro DEFINE_ISR_ERRCODE 1
[GLOBAL isr%1]
isr%1:
cli
push byte %1
jmp isr_common_stub
%endmacro
; exceptions/interrupts without error code
%macro DEFINE_ISR_NOERRCODE 1
[GLOBAL isr%1]
isr%1:
cli
push byte 0
push byte %1
jmp isr_common_stub
%endmacro
而后咱们就能够定义所有 isr*:
DEFINE_ISR_NOERRCODE 0
DEFINE_ISR_NOERRCODE 1
DEFINE_ISR_NOERRCODE 2
DEFINE_ISR_NOERRCODE 3
DEFINE_ISR_NOERRCODE 4
DEFINE_ISR_NOERRCODE 5
DEFINE_ISR_NOERRCODE 6
DEFINE_ISR_NOERRCODE 7
DEFINE_ISR_ERRCODE 8
DEFINE_ISR_NOERRCODE 9
...
为什么会有两种 isr
定义呢?其实下面对于 CPU 主动压栈有一点遗记说了,除了下面提到的对于 instruction
和 stack
的信息保留,对于某些 exception
,CPU 还会压入一个 error code。至于哪些 exception
会压入 error code,能够参考下面给出那张表格。
因为存在这么一个奇葩的不一致性,为了对立起见,对于那些不会压入 error code 的 exception
,咱们手动补充一个 0 进去。
所以总结下,isr
是中断解决的总入口,它次要做了这几件事件:
- 敞开
interrupt
,留神这个只能屏蔽硬中断,对于exception
是有效的; - 压入中断号码;
- 跳转进入
isr_common_stub
;
来到 isr_common_stub
,代码在 src/interrupt/idt.S,这里是保留和复原 data 相干寄存器上下文,以及进入真正的中断解决的中央。
这是它的前半段:
[EXTERN isr_handler]
isr_common_stub:
; save common registers
pusha
; save original data segment
mov ax, ds
push eax
; load the kernel data segment descriptor
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
call isr_handler
紧接着是它的后半段:
interrupt_exit:
; recover the original data segment
pop eax
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
popa
; clean up the pushed error code and pushed ISR number
add esp, 8
; make sure interrupt is enabled
sti
; pop cs, eip, eflags, user_ss, and user_esp by processor
iret
这里其实是只有一个函数 isr_common_stub
(前半段并没有 ret
)。interrupt_exit
只是我加的一个标记,因为当前在别的中央会用到。当然其实汇编里原本也没什么函数的概念,实质上都是标记而已。
咱们看到前半段做了几件事件:
pusha
保留了所有通用寄存器;- 接着保留 data 段寄存器
ds
; - 批改 data 段寄存器为 kernel 的,而后调用真正的中断解决逻辑
isr_handler
(前面再讲);
在中断处理完毕后,后半段的复原阶段做了以下几件事件,实质上是前半段的逆操作:
- 复原原来的
data
段寄存器; popa
复原所有通用寄存器;- 跳过栈里的 error code 和中断号;
- 复原中断并返回;
这样咱们能够画出残缺的中断产生时的 stack
,其中绿色局部是保留的中断上下文,包含了 CPU 主动压入和咱们本人压入的局部:
其中下方粉红色局部是真正的中断解决外围函数 isr_handler
,它是用 C 语言编写的:
typedef void (*isr_t)(isr_params_t);
void isr_handler(isr_params_t regs);
其中参数 isr_params_t
构造定义为:
typedef struct isr_params {
uint32 ds;
uint32 edi, esi, ebp, esp, ebx, edx, ecx, eax;
uint32 int_num;
uint32 err_code;
uint32 eip, cs, eflags, user_esp, user_ss;
} isr_params_t;
之所以 isr_handler
能够以此构造作为参数,正是因为图中绿色局部的压栈,而后通过 call isr_handler
,绿色局部就正好就对应了 isr_params_t
构造。红色箭头指向的正是参数 isr_params_t
的地址。
通过 isr_params_t
这个参数,咱们在 isr_handler 里就能获取无关中断的所有信息:
void isr_handler(isr_params_t params) {
uint32 int_num = params.int_num;
// ...
// Bottom half of interrupt handler - now interrupt is re-enabled.
enable_interrupt();
// handle interrupt
if (interrupt_handlers[int_num] != 0) {isr_t handler = interrupt_handlers[int_num];
handler(params);
} else {monitor_printf("unknown interrupt: %d\n", int_num);
PANIC();}
}
上半局部都是对于 CPU 和中断外设芯片的必要交互,你能够临时疏忽。isr_handler
作为一个通用的中断解决入口,它会依据中断号来找到对应中断的真正处理函数,这些函数咱们定义在了 interrupt_handlers
这个数组里:
static isr_t interrupt_handlers[256];
它们通过 register_interrupt_handler
函数来设置:
void register_interrupt_handler(uint8 n, isr_t handler) {interrupt_handlers[n] = handler;
}
以上的代码都位于 src/interrupt/ 中,代码不多然而比拟绕,仔细阅读应该不难理解。
关上时钟 interrupt
以上都是实践局部,咱们须要一个真正的中断来实际一下成果。最现实的 interrupt
当然是时钟(timer
)中断,这也是前面用来驱动多任务切换的外围中断。初始化 timer
的代码在 src/interrupt/timer.c 里,这里不多赘述,次要都是硬件端口相干的操作,设置了时钟频率,以及最重要的注册中断处理函数:
register_interrupt_handler(IRQ0_INT_NUM, &timer_callback);
IRQ0_INT_NUM
就是 32,这是时钟中断号;timer_callback
咱们能够简略做打印解决:
static uint32 tick = 0;
static void timer_callback(isr_params_t regs) {monitor_printf("tick = %d\n", tick++);
}
而后就能够尝试验证:
int main() {init_gdt();
monitor_clear();
init_idt();
init_timer(TIMER_FREQUENCY);
enable_interrupt();
while (1) {}}
运行 bochs,运气好的话能看到这个:
触发 exception
timer
是硬中断 interrupt
,咱们再来看一个 exception
的例子,比方 page fault
,中断号为 14:
register_interrupt_handler(14, page_fault_handler);
void page_fault_handler(isr_params_t params) {monitor_printf("page fault!\n");
}
如何引发 page fault
?很简略,只有咱们去拜访一个 page table
里没有被映射的 virtual 地址就能够了。
int main() {init_gdt();
monitor_clear();
init_idt();
register_interrupt_handler(14, page_fault_handler);
int* ptr = (int*)0xD0000000;
*ptr = 5;
while (1) {}}
void page_fault_handler(isr_params_t params) {monitor_printf("page fault!\n");
}
运行 bochs,运气好的话能看到这个:
能够看到它在不停地打印,这是因为咱们的 page fault 处理函数除了打印什么也没做。page fault 解决完结后,CPU 会尝试再度去拜访之前引发 page fault 的内存地址,所以会再次触发 page fault。对于 page fault
的真正解决,咱们留待下一篇,虚拟内存欠缺。