发布于 2026-01-06 4 阅读
0

编写我自己的键盘驱动程序简介 设置IDT 设置IRQ处理程序 键盘处理程序 新内核

编写我自己的键盘驱动程序

介绍

设置IDT

设置 IRQ 处理程序

键盘处理器

新内核

介绍

在上一篇文章中,我们实现了一个视频驱动程序,以便能够在屏幕上打印文本。然而,为了使操作系统对用户有用,我们也希望用户能够输入命令。文本输入和输出将是未来 shell 功能的基础。

那么键盘和操作系统之间是如何通信的呢?键盘通过物理端口(例如串口、PS/2、USB)连接到计算机。如果是PS/2接口,数据由位于主板上的微控制器接收。当按下某个键时,微控制器会将相关信息存储在I/O端口中,并向可编程中断控制器(PIC0x60 )发送中断请求IRQ 1

PIC随后根据外部IRQ,使用预定义的中断号中断CPU。CPU接收到中断后,会查询中断描述符表(IDT),找到需要调用的相应中断处理程序。处理程序完成其任务后,CPU将从中断发生前的状态恢复正常执行。

为了使整个流程正常工作,我们需要在内核初始化期间进行一些准备工作。首先,我们必须在 PIC 内部设置正确的映射,以便将 IRQ 正确转换为实际中断。然后,我们必须创建并加载一个有效的 IDT,其中包含对键盘处理程序的引用。该处理程序随后从相应的 I/O 端口读取所有相关数据,并将其转换为我们可以显示给用户的文本,例如“输入”或LCTRL“输入” A

现在我们已经了解了需要完成的任务的大致内容,让我们开始吧!本文的剩余部分结构如下。下一节将重点介绍如何定义和加载中断处理程序 (IDT)。之后,我们将实现键盘中断处理程序并进行注册。最后,我们将扩展内核功能,使其能够按正确的顺序执行新编写的代码。

代码可在 GitHub 上获取。本文中的代码示例使用了类型别名,#include <stdint.h>这些别名比原始 C 类型结构更清晰。uint16_t例如,`__init__` 对应于一个无符号 2 字节(16 位)值。

设置IDT

IDT结构

中断类型表 (IDT) 由 256 个描述符条目组成,称为门。每个门长 8 字节,并对应一个唯一的中断号,该中断号由其在表中的位置确定。门分为三种类型:任务门、中断门和陷阱门。中断门和陷阱门可以调用自定义处理函数,其中中断门会在处理函数调用期间暂时禁用硬件中断处理,这使其适用于处理硬件中断。任务门允许使用硬件任务切换机制将处理器控制权传递给另一个程序。

我们目前只需要定义中断门。一个中断门包含以下信息:

  • 偏移量。32位偏移量表示相应代码段内中断处理程序的内存地址。
  • 选择器。调用处理程序时要跳转到的代码段的 16 位选择器。这将是我们的内核代码段。
  • 类型。3110位,指示门电路类型。由于我们定义的是中断门,因此将设置为。
  • D. 1 位指示代码段是否为 32 位。将被设置为1
  • DPL。2位描述符特权级别指示调用处理程序所需的特权。将被设置为00
  • P. 1 位指示门是否有效。将被设置为1.
  • 0.0中断门电路中一些必须始终设置的位。

下图展示了IDT门的布局。

IDT门结构

要在 C 语言中创建 IDT 门,我们首先定义idt_gate_t结构体类型。__attribute__((packed))这告诉gcc编译器尽可能紧密地将数据打包到结构体中。否则,编译器可能会添加填充,例如为了优化结构体布局以适应 CPU 缓存大小。

typedef struct {
    uint16_t low_offset;
    uint16_t selector;
    uint8_t always0;
    uint8_t flags;
    uint16_t high_offset;
} __attribute__((packed)) idt_gate_t;
Enter fullscreen mode Exit fullscreen mode

现在我们可以将中断处理程序 (IDT) 定义为一个包含 256 个门的数组,并实现一个设置函数set_idt_gate来注册handler中断n。我们将使用两个辅助函数来分割处理程序的 32 位内存地址。

#define low_16(address) (uint16_t)((address) & 0xFFFF)
#define high_16(address) (uint16_t)(((address) >> 16) & 0xFFFF)

idt_gate_t idt[256];

void set_idt_gate(int n, uint32_t handler) {
    idt[n].low_offset = low_16(handler);
    idt[n].selector = 0x08; // see GDT
    idt[n].always0 = 0;
    // 0x8E = 1  00 0 1  110
    //        P DPL 0 D Type
    idt[n].flags = 0x8E;
    idt[n].high_offset = high_16(handler);
}
Enter fullscreen mode Exit fullscreen mode

设置内部中断服务例程

中断处理程序也称为中断服务例程 (ISR)。前 32 个 ISR 保留用于处理 CPU 特定的中断,例如异常和故障。设置这些 ISR 至关重要,因为这是我们在重新映射 PIC 并定义后续的 IRQ 时,判断是否存在错误的唯一方法。您可以在源代码或维基百科上找到完整的列表。

首先,我们用 C 语言定义一个通用的中断服务例程 (ISR) 处理函数。它可以提取与中断相关的所有必要信息并采取相应的操作。目前,我们将使用一个简单的查找数组,其中包含每个中断编号的字符串表示形式。

char *exception_messages[] = {
    "Division by zero",
    "Debug",
    \\ ...
    "Reserved"
};

void isr_handler(registers_t *r) {
    print_string(exception_messages[r->int_no]);
    print_nl();
}
Enter fullscreen mode Exit fullscreen mode

为了确保我们掌握所有信息,我们将向registers_t如下定义的函数传递一个结构体:

typedef struct {
    // data segment selector
    uint32_t ds;
    // general purpose registers pushed by pusha
    uint32_t edi, esi, ebp, esp, ebx, edx, ecx, eax;
    // pushed by isr procedure
    uint32_t int_no, err_code;
    // pushed by CPU automatically
    uint32_t eip, cs, eflags, useresp, ss;
} registers_t;
Enter fullscreen mode Exit fullscreen mode

这个结构体之所以如此复杂,是因为我们要从汇编代码中调用处理函数(用 C 语言编写)。在函数被调用之前,C 语言要求参数必须存在于栈上。栈上已经包含一些信息,而我们在这里添加额外的信息。

以下是定义前 32 个中断服务例程 (ISR) 的汇编代码片段。遗憾的是,我们无法得知是哪个门触发了处理程序,因此每个门都需要一个处理程序。我们需要定义标签,global以便稍后在 C 代码中引用它们。

global isr0
global isr1
; ...
global isr31

; 0: Divide By Zero Exception
isr0:
    push byte 0
    push byte 0
    jmp isr_common_stub

; 1: Debug Exception
isr1:
    push byte 0
    push byte 1
    jmp isr_common_stub

; ...

; 12: Stack Fault Exception
isr12:
    ; error info pushed by CPU
    push byte 12
    jmp isr_common_stub

; ...

; 31: Reserved
isr31:
    push byte 0
    push byte 31
    jmp isr_common_stub
Enter fullscreen mode Exit fullscreen mode

每个过程都会确保在将控制权交给公共中断服务例程 (ISR) 之前,栈上存在 ` i`int_no和`j` 字节,我们稍后会详细介绍 ISR。第一个 `push` 字节(如果存在)表示特定异常(例如栈错误)的错误信息。如果发生此类异常,CPU 会将此错误信息压入栈中。为了确保所有 ISR 的栈保持一致,即使没有错误信息,我们也会压入一个字节。第二个 `push` 字节对应于中断号。err_codeerr_code0

现在我们来看一个常见的中断服务例程 (ISR) 过程。它会将所有必要信息填充到栈中registers_t,准备段指针以调用内核 ISR 处理程序isr_handler,将栈指针(registers_t实际上是指向的指针)压入栈,调用isr_handler,并在之后进行清理,以便 CPU 可以从中断处恢复执行。isr_handler必须将其标记为extern,因为它将在 C 语言中定义。

[extern isr_handler]

isr_common_stub:
    ; push general purpose registers
    pusha

    ; push data segment selector
    mov ax, ds
    push eax

    ; use kernel data segment
    mov ax, 0x10
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    ; hand over stack to C function
    push esp
    ; and call it
    call isr_handler
    ; pop stack pointer again
    pop eax

    ; restore original segment pointers segment
    pop eax
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    ; restore registers
    popa

    ; remove int_no and err_code from stack
    add esp, 8

    ; pops cs, eip, eflags, ss, and esp
    ; https://www.felixcloutier.com/x86/iret:iretd
    iret
Enter fullscreen mode Exit fullscreen mode

最后,我们可以使用set_idt_gate之前的函数在 IDT 中注册前 32 个 ISR。我们将所有调用都包装在isr_install.

void isr_install() {
    set_idt_gate(0, (uint32_t) isr0);
    set_idt_gate(1, (uint32_t) isr1);
    // ...
    set_idt_gate(31, (uint32_t) isr31);
}
Enter fullscreen mode Exit fullscreen mode

现在我们已经实现了 CPU 内部中断处理程序,接下来可以重新映射 PIC 并设置 IRQ 处理程序。

重新映射PIC

在我们的 x86 系统中,8259 PIC 单片机负责管理硬件中断。需要注意的是,现代计算机采用了更新的标准——高级可编程中断控制器 ( APIC ),但这超出了本文的讨论范围。我们将使用两个级联的 PIC 单片机,每个单片机可以处理 8 个不同的 IRQ。辅助芯片通过一个 IRQ 连接到主芯片,这样我们就实际上可以处理 15 个不同的 IRQ。

BIOS 会为 PIC 单片机在 16 位实模式下设置合理的默认值,其中前 8 个 IRQ 映射到 IDT 中的前 8 个门。然而,在保护模式下,这些默认值与预留给 CPU 内部中断的前 32 个门发生冲突。因此,我们需要重新编程(重新映射)PIC 单片机以避免冲突。

PIC 的编程可以通过访问相应的 I/O 端口完成。主 PIC 使用0x20(命令) 端口和0x21(数据) 端口。辅助 PIC 使用0xA0(命令) 端口和0xA1(数据) 端口。编程是通过发送四个初始化命令字 (ICW) 来实现的。如果以下段落难以理解,我建议您阅读这份全面的文档

首先,我们需要0x11向两个 PIC 发送初始化命令 ICW1()。然后,它们将等待数据端口上的以下三个输入:

  • 0x20ICW2(IDT偏移)。主PIC设置为(32),0x28副PIC设置为(40)。
  • 0x04ICW3(PIC之间的连接)。我们将指示主PIC在IRQ 2(即)上接受来自辅助PIC的IRQ 。辅助PIC将通过设置=0b00000100来标记为辅助0x020b00000010
  • ICW4(模式)。我们设置0x01=0b00000001以启用 8086 模式。

最后,我们发送第一个操作命令字(OCW1)0x00=0b00000000以启用所有中断请求(不进行掩码)。借助port_byte_out上一篇文章中的功能,我们可以扩展isr_install以执行如下的 PIC 重映射。

void isr_install() {
    // internal ISRs
    // ...

    // ICW1
    port_byte_out(0x20, 0x11);
    port_byte_out(0xA0, 0x11);

    // ICW2
    port_byte_out(0x21, 0x20);
    port_byte_out(0xA1, 0x28);

    // ICW3
    port_byte_out(0x21, 0x04);
    port_byte_out(0xA1, 0x02);

    // ICW4
    port_byte_out(0x21, 0x01);
    port_byte_out(0xA1, 0x01);

    // OCW1
    port_byte_out(0x21, 0x0);
    port_byte_out(0xA1, 0x0);
}
Enter fullscreen mode Exit fullscreen mode

现在我们已经成功地将 PIC 重新映射到中断门 32-47,以便向其发送 IRQ,我们可以注册相应的 ISR。

设置 IRQ 处理程序

添加用于处理中断请求的中断服务例程 (ISR) 与我们之前创建的前 32 个 CPU 内部 ISR 非常相似。首先,我们通过添加用于处理 IRQ 0-15 的门电路来扩展中断检测树 (IDT)。

void isr_install() {
    // internal ISRs
    // ...

    // PIC remapping
    // ...

    // IRQ ISRs (primary PIC)
    set_idt_gate(32, (uint32_t)irq0);
    // ...
    set_idt_gate(39, (uint32_t)irq7);

    // IRQ ISRs (secondary PIC)
    set_idt_gate(40, (uint32_t)irq8);
    // ...
    set_idt_gate(47, (uint32_t)irq15);
}
Enter fullscreen mode Exit fullscreen mode

然后,我们将 IRQ 过程标签添加到汇编代码中。在调用之前,我们将 IRQ 号和中断号压入堆栈irq_common_stub

global irq0
; ...
global irq15

irq0:
    push byte 0
    push byte 32
    jmp irq_common_stub

; ...

irq15:
    push byte 15
    push byte 47
    jmp irq_common_stub
Enter fullscreen mode Exit fullscreen mode

irq_common_stub其定义与此类似isr_common_stub,它将调用 C 函数irq_handler。不过,IRQ 处理程序将采用更模块化的设计,因为我们希望能够在加载内核时动态添加各个处理程序,例如键盘处理程序。为此,我们初始化一个中断处理程序数组,isr_t这些处理程序是接受先前定义的参数的函数registers_t

typedef void (*isr_t)(registers_t *);

isr_t interrupt_handlers[256];
Enter fullscreen mode Exit fullscreen mode

基于此,我们可以编写通用中断处理程序irq_handler。它会根据中断号从数组中检索相应的处理程序,并使用给定的参数调用它registers_t。请注意,由于 PIC 协议的限制,我们必须向相关的 PIC 发送中断结束 ( EOI ) 命令(IRQ 0-7 仅向主 PIC 发送,IRQ 8-15 则向两个 PIC 都发送)。这是为了让 PIC 知道中断已被处理,从而可以发送后续中断。以下是代码:

void irq_handler(registers_t *r) {
    if (interrupt_handlers[r->int_no] != 0) {
        isr_t handler = interrupt_handlers[r->int_no];
        handler(r);
    }

    port_byte_out(0x20, 0x20); // primary EOI
    if (r->int_no < 40) {
        port_byte_out(0xA0, 0x20); // secondary EOI
    }
}
Enter fullscreen mode Exit fullscreen mode

现在我们几乎完成了。IDT 已经定义好,我们只需要告诉 CPU 加载它。

加载IDT

可以使用指令加载 IDT lidt。准确地说,lidt该指令并非加载 IDT 本身,而是加载 IDT 描述符。IDT 描述符包含 IDT 的大小(以字节为单位的限制)和基地址。我们可以将该描述符建模为如下所示的结构体:

typedef struct {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed)) idt_register_t;
Enter fullscreen mode Exit fullscreen mode

然后我们可以调用lidt一个名为 `setbase` 的新函数load_idt。它通过获取指向门阵列的指针来设置基址idt,并通过将 IDT 门的数量 (256) 乘以每个门的大小来计算内存限制。通常情况下,限制值等于门的大小减 1。

idt_register_t idt_reg;

void load_idt() {
    idt_reg.base = (uint32_t) &idt;
    idt_reg.limit = IDT_ENTRIES * sizeof(idt_gate_t) - 1;
    asm volatile("lidt (%0)" : : "r" (&idt_reg));
}
Enter fullscreen mode Exit fullscreen mode

接下来是isr_install函数的最终修改,即在安装完所有 ISR 后加载 IDT。

void isr_install() {
    // internal ISRs
    // ...

    // PIC remapping
    // ...

    // IRQ ISRs
    // ...

    load_idt();
}
Enter fullscreen mode Exit fullscreen mode

本文的IDT部分到此结束,我们终于可以开始编写键盘相关的代码了。毕竟,这是一篇关于键盘驱动程序的博客文章,对吧?

键盘处理器

当按下某个键时,我们需要一种方法来识别是哪个键被按下。这可以通过读取相应按键的扫描码来实现。请注意,扫描码区分按键的按下(向下)和释放(向上)。释放按键的扫描码可以通过0x80在相应按键按下的扫描码上加上一个值来计算。

一个switch语句包含我们当前需要处理的所有按键按下扫描码。如果某个扫描码与这些情况都不匹配,可能有以下三种原因:要么是未知按键按下,要么是按键已释放。如果释放的按键在我们预期的范围内,我们只需0x80从扫描码中减去该值。我们可以将此逻辑放入一个print_letter函数中:

void print_letter(uint8_t scancode) {
    switch (scancode) {
        case 0x0:
            print_string("ERROR");
            break;
        case 0x1:
            print_string("ESC");
            break;
        case 0x2:
            print_string("1");
            break;
        case 0x3:
            print_string("2");
            break;
        // ...
        case 0x39:
            print_string("Space");
            break;
        default:
            if (scancode <= 0x7f) {
                print_string("Unknown key down");
            } else if (scancode <= 0x39 + 0x80) {
                print_string("key up ");
                print_letter(scancode - 0x80);
            } else {
                print_string("Unknown key up");
            }
            break;
    }
}
Enter fullscreen mode Exit fullscreen mode

请注意,扫描码与键盘类型相关。例如,上面的扫描码适用于 IBM PC 兼容的 PS/2 键盘。USB 键盘使用不同的扫描码。接下来,我们需要实现并注册一个按键中断处理函数。PIC 在发送 IRQ 1 后会将扫描码保存在端口中。因此,我们需要在 IRQ 1(映射到中断号 33)处0x60实现并注册该函数。keyboard_callback

static void keyboard_callback(registers_t *regs) {
    uint8_t scancode = port_byte_in(0x60);
    print_letter(scancode);
    print_nl();
}
Enter fullscreen mode Exit fullscreen mode
#define IRQ1 33

void init_keyboard() {
    register_interrupt_handler(IRQ1, keyboard_callback);
}
Enter fullscreen mode Exit fullscreen mode

我们快完成了!只剩下修改主内核函数这一件事了。

新内核

新的内核函数需要将所有组件整合在一起。它必须安装中断服务例程 (ISR),实际上就是加载我们的中断类型定义 (IDT)。然后,它会通过设置中断标志来启用外部中断sti。最后,我们可以调用init_keyboard注册键盘中断处理程序的函数。

void main() {
    clear_screen();
    print_string("Installing interrupt service routines (ISRs).\n");
    isr_install();

    print_string("Enabling external interrupts.\n");
    asm volatile("sti");

    print_string("Initializing keyboard (IRQ 1).\n");
    init_keyboard();
}
Enter fullscreen mode Exit fullscreen mode

现在启动电脑,然后输入点什么……

演示

太棒了!有了VGA驱动和键盘驱动,我们就可以在下一篇文章中着手编写一个简单的shell了:)


封面图片来自Unsplash 的John Karlo Mendoza

如果你喜欢这篇文章,可以在 ko-fi 上支持我

文章来源:https://dev.to/frosnerd/writing-my-own-keyboard-driver-16kh