Appearance
中断与异常
这一章我想把“中断”讲到能拿着代码一行一行对照的程度。
在这个内核里,中断相关代码大致分三层: CPU/硬件侧负责“触发 + 压栈 + 查 IDT + 跳转”,我在内核侧负责把 IDT 建起来、把每个向量号接到统一的汇编入口,然后再交给 C/C++ 的分发函数;外设这边(PIT 时钟、键盘控制器 8042、硬盘控制器)负责真正产生 IRQ,同时也要求我在处理函数里把“中断源”清掉,不然下一次中断不会再来。
本章会刻意不展开 int 0x80 的系统调用细节:你会看到我在 IDT 里给 0x80 留了一个 DPL=3 的门,但它的 ABI、参数传递、返回值这些我放到系统调用章再讲。
中断系统概述
我写中断系统的时候,脑子里一直抓着一个问题:**CPU 到底是在什么时候,把哪些东西压到了栈上?**只要这个栈帧我能解释清楚,后面无论是异常打印、时钟调度还是键盘输入缓冲,都能顺着同一条链路跑通。
为了不把概念讲散,我先把本章里出现的几个关键词定一下(不是背概念,主要是为了后面叙述一致):
异常(Exception):CPU 自己在执行指令的过程中发现不对劲,比如除 0、缺页、一般保护错误。它不是从 8259A 来的,也不叫 IRQ。
外部中断(Interrupt / IRQ):外设通过中断控制器(我这里是 8259A PIC)把信号送到 CPU。典型的就是 PIT 时钟 IRQ0、键盘 IRQ1。
向量号(Vector):在 x86 里,中断/异常最终都会落到一个 0–255 的编号上。CPU 用这个编号去 IDT(中断描述符表)里找入口。
我在 kernel/interrupt.c 里把 IDT 的大小写死成了 0x81(IDT_DESC_CNT),也就是 0x00 到 0x80。 按用途分的话,0x00--0x1F 是 CPU 异常(Intel 保留的那一段),0x20--0x2F 是外设 IRQ(通过 8259A 重映射进来),0x80 我留给系统调用(本章不讲细节,只点一下它为什么必须是 DPL=3)。
这个划分跟我在第二章的中断概览是一致的,但第五章我会把“机制 + 代码路径 + 案例”拆开讲,避免一段话里同时塞概念和实现。
CPU中断处理流程
这一节我站在 CPU 的视角,把“从某个事件发生,到我写的处理函数开始执行”之间,硬件做过的动作讲清楚。
IDTR与IDT
CPU 并不知道我的中断处理函数写在哪里,它只认识一个寄存器:IDTR。IDTR 里放的是 IDT 的线性地址和长度。
在我的实现里,IDT 这张表本身是一个全局数组 idt[],定义在 kernel/interrupt.c。初始化阶段 idt_init() 会拼一个 idt_operand 传给 lidt:
c
auto idt_operand = (sizeof(idt) - 1) | ((u64)(u32)idt << 16);
asm volatile("lidt %0": : "m"(idt_operand));这句写法看起来有点怪,其实就是在构造一个“伪描述符”:低 16 位是 limit,高位是 base。CPU 执行 lidt 后,就把这段内存当成 IDT 的位置,从此每次发生中断都会用向量号去查表。
中断门与陷阱门
IDT 的每一项叫“门描述符”。对 32 位保护模式来说,我们最常用的是 32 位中断门(type=0xE)。
我在 kernel/include/global.h 里把 IDT 的属性位封装成了两个常量:
c
auto constexpr IDT_DESC_ATTR_DPL0 = (IDT_DESC_P<<7) + (IDT_DESC_DPL0<<5) + IDT_DESC_32_TYPE;
auto constexpr IDT_DESC_ATTR_DPL3 = (IDT_DESC_P<<7) + (IDT_DESC_DPL3<<5) + IDT_DESC_32_TYPE;这里的关键点有两个:
DPL 决定“谁允许用 int n 主动触发这个门”。比如系统调用要让用户态也能 int 0x80,所以它必须是 DPL=3;而绝大多数异常/IRQ 都应该是 DPL=0。
中断门 vs 陷阱门 的差异点在 IF 标志位:中断门会自动清 IF(相当于硬件帮你关中断),陷阱门不会。
我的实现里 IDT 用的都是 32 位中断门,所以进入中断后 IF 会被清掉,保证处理过程不会被可屏蔽中断重入打断。
中断栈帧
中断真正“硬核”的地方就在这里:CPU 会自动往当前栈里压一段返回现场,然后把控制权交给 IDT 里指定的入口。
如果不涉及特权级切换(比如当前就是 ring0),CPU 一般会按这个顺序压栈:
text
EIP
CS
EFLAGS
(某些异常还会额外压 error code)如果发生了特权级切换(典型是 ring3 进 ring0),CPU 还会先切换栈,然后再多压两个东西:
text
(切换到 tss.esp0 指定的 0 级栈)
旧的 SS
旧的 ESP
EIP
CS
EFLAGS
(error code?)我这个内核虽然平时大部分代码跑在 ring0,但我还是把 TSS 和用户段都建好了(下一节会详细讲),因为“栈切换”是理解异常/中断机制绕不开的一块。
异常错误码
你会发现某些异常会多压一个 32 位的 error code(比如 #PF 缺页、#GP 一般保护错误)。这个 error code 是 CPU 给的一点额外信息,它的具体位意义取决于异常类型。
在我的汇编入口里,我做了一个挺实用的取巧:无论这个异常有没有 error code,都让栈布局对齐成“都有”。实现方式就是:
有 error code 的异常:不做额外压栈(因为 CPU 已经压了)
没有 error code 的异常:我手动
push 0
这样我在统一出口 intr_exit 里就能固定 add esp, 4 把“error code 槽位”弹掉。
这个逻辑在 kernel/interrupt.asm 里用两个宏表达得很直白:
text
%define ERROR_CODE nop
%define ZERO push 0这也解释了为什么我在 C 侧的通用异常处理函数 general_intr_handler(u8 vec_nr) 拿不到 error code:我没有把它当参数传过去。 我这里的目标是“能定位异常类型、必要时打印缺页地址(CR2)”,所以就没把 error code 这条路继续深挖。

IDT初始化
这一节开始就不抽象了,我直接拿我写的结构体和函数来讲 IDT。
门描述符结构体
在 kernel/interrupt.c 里,我把一个 IDT 表项定义成了 gate_desc:
c
typedef struct gate_desc {
u16 func_offset_low_word;
u16 selector;
u8 dcount;
u8 attribute;
u16 func_offset_high_word;
} gate_desc;这份布局就是典型的 32 位中断门格式:
offset:处理函数入口的 32 位地址,被拆成 low/high 两段selector:目标代码段选择子,我这里统一用SELECTOR_K_CODEattribute:P/DPL/TYPE 这些位dcount:调用门相关(我这里不用,置 0)
中断门描述符构造
我写了一个小工具函数 make_idt_desc(),它干的事就是把 intr_entry_table[i] 的地址拆成两段填进去:
c
void static make_idt_desc(gate_desc* gdesc, u8 attr, intr_handler function) {
*gdesc = (gate_desc){
.func_offset_low_word = (u32)function & 0x0000FFFF,
.func_offset_high_word = ((u32)function & 0xFFFF0000) >> 16,
.dcount = 0,
.attribute = attr,
.selector = SELECTOR_K_CODE
};
}这里我刻意不在 C 里写一大堆位运算去拼门描述符,因为那样读起来太难受。用结构体字段对照手册,写出来反而更直观。
中断向量初始化
idt_desc_init() 会把 0 到 IDT_DESC_CNT-1 的所有项都填满:
c
for (auto i = 0; i != IDT_DESC_CNT; ++i) {
make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);
}我这张表里比较特殊的是最后一个:
c
auto constexpr sysi = IDT_DESC_CNT - 1;
make_idt_desc(&idt[sysi], IDT_DESC_ATTR_DPL3, syscall_handler);注意我这里只是在“门权限”层面把它放开到 DPL=3,方便后续用户态通过 int 0x80 进入内核;至于系统调用号、参数、返回值怎么走,本章不讲。
异常处理初始化
我不想让异常发生时屏幕上什么都不显示,所以 exception_init() 给每个向量号都注册了一个默认处理函数 general_intr_handler,同时初始化了 intr_name[]:
c
for (auto i = 0; i != IDT_DESC_CNT; ++i) {
idt_table[i] = general_intr_handler;
intr_name[i] = "unknown";
}后面设备驱动要挂中断处理函数,走的是 register_handler(vector, func),它本质就是改 idt_table[vector]。
通用异常处理
general_intr_handler(u8 vec_nr) 的逻辑非常“调试导向”:
c
// 通用的中断处理函数,一般用于异常出现时的处理
void static general_intr_handler(u8 vec_nr)
{
if(vec_nr == 0x27 || vec_nr == 0x2f) {
// IRQ7与IRQ15 伪中断,无需处理
return;
}
// 清屏,从屏幕左上角打印异常信息
set_cursor(0);
auto cursor_pos = 0;
while(cursor_pos < 320) { // 清空前4行
putchar(' ');
++cursor_pos;
}
set_cursor(0);
puts("!!!!!!! exception message begin !!!!!!!\n");
set_cursor(88);
puts(intr_name[vec_nr]);
if(vec_nr == 14) { // 缺页异常
u32 page_fault_vaddr = 0;
asm ("movl %%cr2, %0" : "=r"(page_fault_vaddr));
puts("\npage fault addr is ");
puthex(page_fault_vaddr);
}
puts("\n!!!!!!! exception message end !!!!!!!\n");
while(true) { } // 死循环停住
}这段代码的逻辑很直接:先判断是不是伪中断(IRQ7/IRQ15),如果是就直接返回;否则清屏后在屏幕顶部打印异常名字,如果是缺页异常还会额外读 CR2 寄存器把缺页地址打印出来。最后用死循环停住,防止继续跑出更奇怪的二次错误。
统一汇编入口
如果只看 C 代码,很容易以为“中断就是调用一个函数”。现实没这么简单:中断入口不是普通的 call,而是 CPU 帮你跳进来,同时压了一坨返回现场。
我在 kernel/interrupt.asm 做的事情,就是把这坨现场整理成一个稳定的形状,然后把向量号当参数传给 C 层的 idt_table[vec]。
中断入口表
在 C 里,我需要拿到每个向量号对应的入口地址,于是我让汇编导出一个数组:
text
global intr_entry_table
intr_entry_table:
; 每个 VECTOR 宏都会在 data 段 dd 一个入口地址配合 C 里的声明:
c
extern intr_handler intr_entry_table[IDT_DESC_CNT];这样 idt_desc_init() 就能用 intr_entry_table[i] 填 IDT。
VECTOR宏
我把“每个向量号的入口流程”写成了宏 VECTOR vec, has_error_code。宏体里有几个关键动作:
text
%macro VECTOR 2
intr%1entry:
%2 ; ZERO 或 ERROR_CODE:统一 error code 槽位
push ds
push es
push fs
push gs
pushad
mov al, 0x20
out 0xa0, al
out 0x20, al
push %1
call [idt_table + %1 * 4]
jmp intr_exit
section .data
dd intr%1entry
%endmacro我按执行顺序解释一下它的目的。
错误码槽位统一
宏参数 %2 只会是两种东西:
ZERO:push 0ERROR_CODE:nop
也就是说:没有 error code 的异常,我手动补一个 0;有 error code 的异常,什么都不做。
最终效果是:进入 intr_exit 的时候,无论是哪种异常,栈顶都会留一个 4 字节的位置让我弹掉。
段寄存器与通用寄存器保存
我这里选择了一个“朴素但稳”的做法:
先
push ds/es/fs/gs再
pushad
这样我不需要假设编译器会帮我保存任何东西,因为中断不是 C ABI 的函数调用。
EOI发送时机
你会注意到我在 call idt_table[vec] 之前就做了 EOI:
text
mov al, 0x20
out 0xa0, al
out 0x20, al这是一种“统一早发”的策略:不管是不是来自从片的 IRQ,我都对主片/从片各发一次。
它的好处是实现简单,驱动侧就不用纠结“我这次中断到底来自主片还是从片”。代价也很明显:
对纯异常(不是 PIC 来的)来说,这两次
out其实没意义对外设 IRQ 来说,如果处理函数里不及时清中断源(比如不读键盘 0x60),PIC 虽然收到 EOI 了,但设备仍然会继续拉低中断线,导致中断风暴
我的代码能稳定跑起来,一个重要原因就是:键盘中断处理函数一定会读 0x60,硬盘中断处理函数会做对应的 ACK/状态读取。
向量号参数传递
我用 push %1 把向量号压栈,然后 call [idt_table + %1 * 4]。
注意这里的 idt_table 不是 IDT,它是 C 侧注册的“分发函数表”。在 kernel/interrupt.c 里,register_handler() 就是在改这个表。
统一中断出口
无论哪个向量号,最后都会 jmp intr_exit:
text
intr_exit:
add esp, 4 ; 丢弃 vec_nr(对应 push %1)
popad
pop gs
pop fs
pop es
pop ds
add esp, 4 ; 丢弃 error_code 槽位(真实的或补的 0)
iretd这里的两个 add esp, 4 很关键:
第一个对应向量号参数
第二个对应 error code 槽位
最后 iretd 会把 CPU 自动压栈的 EIP/CS/EFLAGS/(SS/ESP) 弹回去,恢复到中断发生前的现场。

8259A PIC初始化
外设的中断线叫 IRQ0–IRQ15,但 CPU 进 IDT 用的是向量号。中间这层翻译就是 8259A PIC 干的。
中断向量重映射
x86 的 0x00–0x1F 被 CPU 异常占了,如果不重映射,PIC 默认也会从 0x08 开始送中断,这会跟异常向量撞车。
我在 kernel/interrupt.c 的 pic_init() 里把主片重映射到 0x20,从片重映射到 0x28。
ICW1–ICW4初始化
代码里就是一串 outb:
c
outb(PIC_M_CTRL, 0x11);
outb(PIC_M_DATA, 0x20);
outb(PIC_M_DATA, 0x04);
outb(PIC_M_DATA, 0x01);
outb(PIC_S_CTRL, 0x11);
outb(PIC_S_DATA, 0x28);
outb(PIC_S_DATA, 0x02);
outb(PIC_S_DATA, 0x01);我按我理解的含义把它翻译成一句话版本:
0x11:边沿触发 + 级联 + 需要 ICW40x20/0x28:主片从0x20起、从片从0x28起0x04/0x02:主片的 IR2 接从片;从片挂在主片 IR20x01:进入 8086/88 模式

中断屏蔽字
PIC 初始化完之后,我立刻写了两次屏蔽字:
c
outb(PIC_M_DATA, 0xf8);
outb(PIC_S_DATA, 0xbf);这是一个很典型的“只放行我需要的”:
主片
0xf8:只开 IRQ0(时钟)、IRQ1(键盘)、IRQ2(级联)从片
0xbf:只开 IRQ14(硬盘)
我喜欢用这种方式起步,因为它让系统的中断来源很可控,调试时不会被奇怪的 IRQ 搅乱。
IRQ与向量号映射
把“重映射的起始向量号”代进去,其实映射就是加法:
主片 IRQ0–7 对应
0x20–0x27从片 IRQ8–15 对应
0x28–0x2F
我在 kernel/interrupt.asm 里也把 0x20--0x2f 的入口都写出来了,注释里顺手标了哪一个是键盘、哪一个是硬盘。
| IRQ | 向量号 | 典型来源 |
|---|---|---|
| IRQ0 | 0x20 | PIT 时钟 |
| IRQ1 | 0x21 | 键盘 8042 |
| IRQ2 | 0x22 | 主从级联 |
| IRQ14 | 0x2E | 硬盘控制器 |
IRQ 与向量号对应关系
IRQ2 不产生实际中断,仅用于连接从片。

TSS与栈切换
很多人学中断会有一种错觉:中断发生时就是“在当前栈上压几个寄存器”。
这在 ring0 当然没问题,但一旦有用户态(ring3),情况就变了:CPU 必须切到内核栈,不然用户态栈既不安全也不够用。x86 给的标准解法就是 TSS。
我这里实现的是“只用 TSS 的栈切换功能”,不做硬件任务切换(那套太重了,也不符合现在的内核结构)。
GDT中的TSS描述符
TSS 本质也是一个段,只不过它的描述符类型是系统段(TYPE=0x9)。这些属性我放在 kernel/include/global.h 里:
c
auto constexpr DESC_TYPE_TSS = 0x9;
auto constexpr TSS_ATTR_LOW = (DESC_P<<7) + (DESC_DPL0<<5) + (DESC_S_SYS<<4) + DESC_TYPE_TSS;真正把它塞进 GDT 的代码在 kernel/module/thread/sync/tss.cpp 的 tss_init():
c++
*(gdt_desc*)0xc0000920 = make_gdt_desc((u32*)&tss, sizeof(tss) - 1, TSS_ATTR_LOW, TSS_ATTR_HIGH);你会看到我这里用的是一个“固定地址”的 GDT(0xc0000900 起),把 TSS 描述符写在 0xc0000920,也就是第 4 个表项的位置。
这种写法的好处是简单直接,缺点也明显:它假设 GDT 的布局固定。我的内核里 GDT 确实是固定布好的,所以我就这么干了。
TSS栈段与栈指针
TSS 结构体我也在 tss.cpp 里完整定义了,其中对“中断栈切换”最关键的是:
ss0:ring0 的栈段选择子esp0:ring0 的栈顶指针
初始化时我固定 ss0:
c++
tss.ss0 = SELECTOR_K_STACK;而 esp0 我不在 tss_init() 里写死,因为它跟“当前正在运行的进程”有关。
esp0更新
假设我真的跑了用户进程:用户态代码在 ring3 执行时,被时钟 IRQ 打断,CPU 会查 TSS 把栈切到 esp0。这意味着:esp0 必须指向“当前用户进程自己的内核栈”。
我的内核里,每个 task(PCB)本身就占一页,页顶就是它的 0 级栈顶。所以 tss.update(pthread) 的实现特别短:
c++
auto update(task const* pthread) -> void {
esp0 = (u32*)((u32)pthread + PG_SIZE);
}至于“什么时候调”,我放在进程激活那条路径里:
调度选出
nextprocess_activate(next)切页表如果
next->pgdir不为空(说明它是用户进程),更新tss.esp0
对应代码在 kernel/module/thread/sync/schedule.cpp:
c++
if (pthread->pgdir) {
tss.update(pthread);
}这里我还专门写了注释解释原因:内核线程本来就是 0 特权级,中断不会触发栈切换,所以没必要更新 esp0。
加载GDT与TSS
最后一步是让 CPU “知道我这份 TSS 在哪”。
在 tss_init() 里我会重新 lgdt 一次(因为我刚改过 GDT 内容),然后用 ltr 把 TSS 选择子装进任务寄存器 TR:
c++
asm volatile("lgdt %0" : : "m"(gdt_operand));
asm volatile("ltr %w0" : : "r"(SELECTOR_TSS));到这里,“ring3 进 ring0 的栈切换基础设施”就齐了。

时钟中断与调度
时钟中断(IRQ0)是我内核里最重要的外设中断:它不只是“计时”,它还是调度器的脉搏。
我希望这一节读完,你能回答三个问题:
1) PIT 是怎么被我配置成 100Hz 的? 2) 时钟 IRQ 从 8259A 进来之后,我的代码路径是怎么走到 schedule() 的? 3) 为什么调度器要求“必须关中断”,而时钟中断天然就满足这一点?
PIT 8253/8254初始化
初始化代码在 device/time/time.c 的 timer_init()。核心是 frequency_set():
c
outb(PIT_CONTROL_PORT, counter_no << 6 | rwl << 4 | counter_mode << 1);
outb(counter_port, counter_value);
outb(counter_port, counter_value >> 8);我在 device/time/time.h 里把相关常量都写死了:
INPUT_FREQUENCY = 1193180(PIT 输入频率)IRQ0_FREQUENCY = 100(我希望的中断频率)COUNTER0_VALUE = INPUT_FREQUENCY / IRQ0_FREQUENCY
这意味着我的系统大约每 10ms 触发一次 IRQ0。
时钟中断处理函数注册
时钟 IRQ0 被 PIC 重映射后对应向量号 0x20。所以在 timer_init() 里我做了注册:
c
register_handler(0x20, intr_time_handler);register_handler() 在 kernel/interrupt.c 里,它只是改 idt_table[vector]。
这个设计我很喜欢:IDT 只负责“入口”,而驱动只需要关心“我接管哪个向量号”。
IRQ0处理流程
把前面几节串起来,时钟中断发生时,路径是这样的:
text
PIT 计数到 0
→ 拉低 IRQ0
→ 8259A 把 IRQ0 编码成向量号 0x20
→ CPU 查 IDT[0x20] 跳转到 intr0x20entry
→ intr0x20entry 保存寄存器、发 EOI、push 0x20
→ call idt_table[0x20](也就是 intr_time_handler)这一段你可以对照三处:入口汇编在 kernel/interrupt.asm,IDT 分发表在 kernel/interrupt.c,时钟处理函数在 device/time/time.cpp。

时钟中断处理函数
真正的处理函数在 device/time/time.cpp:
c++
auto cur_thread = running_thread();
ASSERT(cur_thread->stack_magic == 0x19870916);
++cur_thread->elapsed_ticks;
++ticks;
if (cur_thread->ticks == 0) {
schedule();
} else {
--cur_thread->ticks;
}我逐行解释一下我当时的思路。
当前线程获取
running_thread() 在 kernel/module/thread/sync/schedule.cpp 里,它用一个很经典的技巧:
PCB 占一页(4KB)
内核栈也在这一页里向下长
所以
esp对齐到 4KB 边界就是 PCB 基址
c++
asm ("mov %%esp, %0" : "=g"(esp));
return reinterpret_cast<task*>(esp & 0xfffff000);栈溢出检测
我在 task 里放了一个魔数 stack_magic,每次进时钟中断都断言它还在,等于是用最高频的 IRQ0 来做“栈溢出探测”。
这属于很实用的自检:线程栈一旦踩坏,后面什么都可能发生,中断里早点抓住更好排查。
时钟计数
ticks(全局)代表系统自开中断以来过了多少个 tickelapsed_ticks(线程私有)代表这个线程占过多少 CPU tick
它们在后续实现 sleep() 和 ps 这种命令时都挺有用。
时间片与调度
我的调度器 schedule() 一上来就断言:
c++
ASSERT(intr_get_status() == INTR_OFF);原因也很直接:调度器要操作就绪队列、切换页表、切换栈,这些都是临界区。
那时钟中断里为什么可以直接调用 schedule()?因为我用的是中断门,进入中断时 IF 会被硬件清掉。也就是说,在 intr_time_handler() 运行期间,可屏蔽中断默认就是关的。
EOI发送时机
你如果严格按很多教程写,会把 EOI 放在 C 处理函数的最后。
我这里 EOI 是在汇编入口就发了,意味着:从 PIC 角度看,它很快就“认为这次 IRQ 已经结束”。
这种策略能工作有个前提:驱动侧必须清中断源。
对时钟来说,中断源是 PIT 自己周期性产生的,它不需要我读某个寄存器去清掉,所以早发 EOI 没问题;对键盘就不一样了,下一节我会把这个点讲透。
键盘中断与输入缓冲
键盘中断(IRQ1)比时钟中断更“像驱动”:你按下一下键,会有扫描码从键盘控制器出来;我在中断里必须把这个扫描码读走、解析出字符/控制键,然后塞进一个输入队列。
这一节我会很细地走完整条路径:
8042 为什么一定要读
0x60我怎么处理
0xE0扩展扫描码通码/断码怎么区分,按下/抬起怎么更新状态
shift/caps/ctrl/alt 这些组合键怎么影响最终字符
最后我把字符交给谁(
solve()→ioqbuf)
键盘中断处理函数注册
键盘 IRQ1 被 PIC 重映射后是 0x21。所以初始化时我注册的是:
c
register_handler(0x21, intr_keyboard_handler);代码在 device/keyboard/keyboard.c 的 keyboard_init()。
键盘数据读取
键盘中断的“中断源”是 8042 控制器的输出缓冲区(Output Buffer)。它里面放着扫描码。
如果我只响应中断但不读取这个缓冲区,8042 会一直认为“数据还没被取走”,它就不会继续产生新的键盘中断。
所以我在 intr_keyboard_handler() 的开头就做了最关键的一句:
c
auto scancode = (u16)inb(0x60);源码旁边我还写了注释提醒自己:不读 0x60,8042 不再继续响应键盘中断。
这也解释了上一节提到的“早发 EOI 的前提”:EOI 只是告诉 PIC “我处理完了”,但键盘设备本身还需要我通过读端口把它的状态推进。
扩展扫描码处理
键盘有些按键是“扩展键”,扫描码会以 0xE0 开头。
我用了一个布尔变量 ext_scancode 做状态机:
c
if (scancode == 0xe0) {
ext_scancode = true;
return;
}
if (ext_scancode) {
scancode |= 0xe000;
ext_scancode = false;
}这段逻辑的含义是:
说明这次中断只拿到了前缀 0xE0,我先记住“下一次扫描码要拼起来”,然后直接返回;下一次进来再把它 OR 上 0xE000,这样我就能区分右 Ctrl、右 Alt 这种扩展键。
通码与断码处理
我用扫描码的最高位来判断断码:
c
auto break_code = (bool)(scancode & 0x0080);如果是断码,我的处理策略是:只更新状态,不产生字符。
对应代码:
c
if (break_code) {
auto make_code = (scancode &= 0xff7f);
if (make_code == ctrl_l_make || make_code == ctrl_r_make) {
ctrl_status = false;
} else if (make_code == shift_l_make || make_code == shift_r_make) {
shift_status = false;
} else if (make_code == alt_l_make || make_code == alt_r_make) {
alt_status = false;
}
return;
}这里 ctrl_status/shift_status/alt_status 这些变量都是 static 的,它们跨中断保存了“按键是否处于按下状态”。
字符解析与修饰键
当扫描码是通码时,我会进入字符解析。
我在 device/keyboard/keyboard.c 定义了一个 keymap[][2]:每个扫描码对应两个字符——不按 shift 的字符和按 shift 的字符。
对数字区、符号键(比如 1 对应 !)来说,规则很简单:shift 按下就取第二列。
对字母键来说,还要叠加 caps lock,所以我写的是:
c
shift ^= shift_down_last;
shift ^= caps_lock_last;也就是说:
只按 shift:大写
只开 caps:大写
shift + caps:又变回小写
这是键盘输入最直观的行为。
控制键处理
我在字符出来之后加了一个很具体的小功能:
c
if (ctrl_down_last && (c == 'l' || c == 'u')) {
c -= 'a';
}这是把 ctrl+l、ctrl+u 转成控制字符,方便 Shell 做“清屏/清行”这类快捷键。
这里 c -= ’a’ 的技巧来自 ASCII:’a’ 是 97,减掉后就落在 0–31 的控制字符区。
输入缓冲写入
解析得到可见字符之后,我不在中断里直接回显到屏幕。
原因很现实:回显策略应该由 Shell 控制,中断里做 I/O 太容易把事情搞复杂。所以我把它交给一个很薄的函数 solve(c)。
solve 的实现不在同一个 C 文件里,而在 device/keyboard/iobuf.cpp:
c++
if (not ioqbuf.full()) {
ioqbuf.put(c);
}这里的 ioqbuf 是一个全局 ioqueue,也就是键盘输入队列。
我这个设计的味道是:中断只负责把数据搬进内核,具体怎么消费由上层决定。
键盘输入处理流程
最后我用一句话把“键盘→中断→字符→队列”这条链路总结一下:
text
按键产生扫描码 → 8042 触发 IRQ1 → 向量号 0x21 进入统一汇编入口
→ 汇编保存现场并发 EOI → 调用 intr_keyboard_handler
→ inb(0x60) 取走扫描码(清中断源) → 状态机解析字符
→ solve(c) → ioqbuf.put(c)
