Skip to content

中断与异常

这一章我想把“中断”讲到能拿着代码一行一行对照的程度。

在这个内核里,中断相关代码大致分三层: 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 的大小写死成了 0x81IDT_DESC_CNT),也就是 0x000x80。 按用途分的话,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 这条路继续深挖。

CPU 中断栈帧对比:同级与跨级

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_CODE

  • attribute: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 只会是两种东西:

  • ZEROpush 0

  • ERROR_CODEnop

也就是说:没有 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) 弹回去,恢复到中断发生前的现场。

我的完整中断栈帧(CPU 压栈 + interrupt.asm 手动压栈)

8259A PIC初始化

外设的中断线叫 IRQ0–IRQ15,但 CPU 进 IDT 用的是向量号。中间这层翻译就是 8259A PIC 干的。

中断向量重映射

x86 的 0x000x1F 被 CPU 异常占了,如果不重映射,PIC 默认也会从 0x08 开始送中断,这会跟异常向量撞车。

我在 kernel/interrupt.cpic_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:边沿触发 + 级联 + 需要 ICW4

  • 0x20/0x28:主片从 0x20 起、从片从 0x28

  • 0x04/0x02:主片的 IR2 接从片;从片挂在主片 IR2

  • 0x01:进入 8086/88 模式

8259A 主从片级联拓扑(从片挂在 IR2)

中断屏蔽字

PIC 初始化完之后,我立刻写了两次屏蔽字:

c
outb(PIC_M_DATA, 0xf8);
outb(PIC_S_DATA, 0xbf);

这是一个很典型的“只放行我需要的”:

  • 主片 0xf8:只开 IRQ0(时钟)、IRQ1(键盘)、IRQ2(级联)

  • 从片 0xbf:只开 IRQ14(硬盘)

我喜欢用这种方式起步,因为它让系统的中断来源很可控,调试时不会被奇怪的 IRQ 搅乱。

IRQ与向量号映射

把“重映射的起始向量号”代进去,其实映射就是加法:

  • 主片 IRQ0–7 对应 0x200x27

  • 从片 IRQ8–15 对应 0x280x2F

我在 kernel/interrupt.asm 里也把 0x20--0x2f 的入口都写出来了,注释里顺手标了哪一个是键盘、哪一个是硬盘。

IRQ向量号典型来源
IRQ00x20PIT 时钟
IRQ10x21键盘 8042
IRQ20x22主从级联
IRQ140x2E硬盘控制器

IRQ 与向量号对应关系

IRQ2 不产生实际中断,仅用于连接从片。

IRQ 线路到 CPU 向量号的映射(避开 0-31 异常号)

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.cpptss_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);
}

至于“什么时候调”,我放在进程激活那条路径里:

  • 调度选出 next

  • process_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 的栈切换基础设施”就齐了。

跨级中断时的栈切换机制(从 TSS 读取 0 级栈)

时钟中断与调度

时钟中断(IRQ0)是我内核里最重要的外设中断:它不只是“计时”,它还是调度器的脉搏。

我希望这一节读完,你能回答三个问题:

1) PIT 是怎么被我配置成 100Hz 的? 2) 时钟 IRQ 从 8259A 进来之后,我的代码路径是怎么走到 schedule() 的? 3) 为什么调度器要求“必须关中断”,而时钟中断天然就满足这一点?

PIT 8253/8254初始化

初始化代码在 device/time/time.ctimer_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

时钟中断处理的全链路(PIT → PIC → CPU → IDT → Handler → Schedule)

时钟中断处理函数

真正的处理函数在 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(全局)代表系统自开中断以来过了多少个 tick

  • elapsed_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.ckeyboard_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+lctrl+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)
键盘中断处理中的扫描码状态机(处理 0xE0 扩展码与断码)
从物理按键到输入缓冲区的完整链路