自制操作系统 - 键盘驱动

键盘驱动

Posted by 王富杰 on Thursday, February 8, 2024

一、键盘中断

在前面介绍中断我们知道 8259a 芯片的0号中断时时钟中断,1号中断就是键盘中断,因此键盘中断的向量是 0x21。如下所示:

#define KEYBOARD_DATA_PORT 0x60
#define KEYBOARD_CTRL_PORT 0x64

void keyboard_handler(int vector)
{
    assert(vector == 0x21);
    send_eoi(vector);                       // 发送中断处理完成信号
    u16 scancode = inb(KEYBOARD_DATA_PORT); // 从键盘读取按键信息扫描码
    LOGK("keyboard input 0x%x\n", scancode);
}

void keyboard_init()
{
    set_interrupt_handler(IRQ_KEYBOARD, keyboard_handler);
    set_interrupt_mask(IRQ_KEYBOARD, true);
}

键盘中断涉及两个端口,0x60是数据端口,0x64读取时是状态寄存器,写入时是控制寄存器。从0x60端口读取扫描码到cpu中,键盘按下和弹起都会产生一个扫描码。键盘初始化设置了键盘中断的处理函数,并开启了键盘中断。当在键盘上按下字母a时并松开,产生两个扫描码,分为打印 0x2a, 0xaa。

二、键盘驱动

键盘扫描码分为通码和断码。通码:按下按键产生的扫描码,断码:抬起按键产生的扫描码。键盘共有三套扫描码,目前我们使用的键盘都是第二套扫描码,但是键盘产生了第二套扫描码之后会被8042芯片转换为第一套扫描码,因此键盘中断获取到的是第一套扫描码。

2.1、8042控制器

8042控制器有两个端口。0x60是数据端口,0x64读取时是状态寄存器,写入时是控制寄存器。

状态寄存器:

  • 0位,输出缓冲区状态:1 表示输出缓冲区满
  • 1位,输入缓冲区状态:1 表示输入缓冲区满
  • 2位,系统标志位:加电时置为 0,自检通过时置为 1
  • 3位,命令/数据位:1 表示输入缓冲区的内容是命令,0 表示输入缓冲区的内容是数据
  • 4位,1 表示键盘启用,0 表示键盘禁用
  • 5位,1 表示发送超时 (存疑)
  • 6位,1 表示接受超时 (存疑)
  • 7位,奇偶校验出错

控制寄存器使用不上。

2.2、键盘驱动实现

如下为键盘驱动的实现:

#define INV 0 // 不可见字符

#define CODE_PRINT_SCREEN_DOWN 0xB7

typedef enum
{
    KEY_NONE,
    KEY_ESC,
    KEY_1,
    ...
    // 以下为自定义按键,为和 keymap 索引匹配
    KEY_PRINT_SCREEN,
} KEY;

static char keymap[][4] = {
    /* 扫描码 未与 shift 组合  与 shift 组合 以及相关状态 */
    /* ---------------------------------- */
    /* 0x00 */ {INV, INV, false, false},   // NULL
    /* 0x01 */ {0x1b, 0x1b, false, false}, // ESC
    /* 0x02 */ {'1', '!', false, false},
    ....
};

static bool capslock_state; // 大写锁定
static bool scrlock_state;  // 滚动锁定
static bool numlock_state;  // 数字锁定
static bool extcode_state;  // 扩展码状态
#define ctrl_state (keymap[KEY_CTRL_L][2] || keymap[KEY_CTRL_L][3])   // CTRL 键状态
#define alt_state (keymap[KEY_ALT_L][2] || keymap[KEY_ALT_L][3])  // ALT 键状态
#define shift_state (keymap[KEY_SHIFT_L][2] || keymap[KEY_SHIFT_R][2])  // SHIFT 键状态

如上所示,通过一个枚举类型定义了键盘所有的扫描码。通过结构体keymap定义了扫描码对应的字符,其中数组的第三个元素判断shift是否被按下,第四元素代表扩展按键是否被按下。接下来就是键盘中断的处理函数,这里不在贴函数的代码实现,因为比较长。核心逻辑是根据按下的扫描码在控制台打印对应的字符。

这里介绍下扩展码,扩展码是第二套编码比第一套编码扩展出来的按键,例如按下右边的alt,会先触发一次扫描码E0,说明这是扩展码,然后再产生一个扫描码38说明是alt键被按下。例如alter的两个状态分别判断是左alt还是右alt。

三、控制键盘LED灯

当滚动锁定,大写锁定,和数字锁定时,键盘上对应的LED灯应置于亮灯状态。PS/2 键盘接受很多种类型的命令(cpu向键盘发命令),命令有一个字节,一些命令有数据,这些数据必须在命令字节发送之后再发送。键盘通过一个 ACK(0xFA) (表示命令已收到) 或者 Resend(0xFE) (表示前一个命令有错误);在发送命令之间需要等待键盘缓冲区为空。

控制键盘LED灯就需要给键盘发命令。实现如下:

#define KEYBOARD_CMD_LED 0xED // 设置 LED 状态
#define KEYBOARD_CMD_ACK 0xFA // ACKcpp

static void keyboard_wait()
{
    u8 state;
    do
    {
        state = inb(KEYBOARD_CTRL_PORT);
    } while (state & 0x02); // 读取键盘缓冲区,直到为空
}

static void keyboard_ack()
{
    u8 state;
    do
    {
        state = inb(KEYBOARD_DATA_PORT);
    } while (state != KEYBOARD_CMD_ACK);
}

static void set_leds()
{
    u8 leds = (capslock_state << 2) | (numlock_state << 1) | scrlock_state;
    keyboard_wait();
    // 设置 LED 命令
    outb(KEYBOARD_DATA_PORT, KEYBOARD_CMD_LED);
    keyboard_ack();

    keyboard_wait();
    // 设置 LED 灯状态
    outb(KEYBOARD_DATA_PORT, leds);
    keyboard_ack();
}

如上所示,只需遵循键盘协议,当按下指定键时,设置键盘对应LED亮即可。这里不必深究,在运行时可以使用bochs,bochs可以模拟键盘的led灯。

四、循环队列

当键盘在被按下,但是此时确没有程序去使用键盘产生的字符。此时就需要把这些字符暂存到队列中去。队列属于常见的数据结构。定义如下:

typedef struct fifo_t
{
    char *buf;
    u32 length;
    u32 head;
    u32 tail;
} fifo_t;

void fifo_init(fifo_t *fifo, char *buf, u32 length);
bool fifo_full(fifo_t *fifo);
bool fifo_empty(fifo_t *fifo);
char fifo_get(fifo_t *fifo);
void fifo_put(fifo_t *fifo, char byte);

队列是先进先出的数据结构,我们这里实现的循环队列,如果队列满了就移除最早入队的那个元素。接下来就可以修改键盘中断处理函数,当键盘产生字符将字符压入队列。不再直接打印。我们这里看下键盘对循环队列的应用:

// LOGK("keydown %c \n", ch);
fifo_put(&fifo, ch);
if (waiter != NULL)
{
    task_unblock(waiter);
    waiter = NULL;
}
// ----------------------------

u32 keyboard_read(char *buf, u32 count)
{
    lock_acquire(&lock);
    int nr = 0;
    while (nr < count)
    {
        while (fifo_empty(&fifo))
        {
            waiter = running_task();  // 如果队列没有数据,就阻塞进行等待。
            task_block(waiter, NULL, TASK_WAITING);
        }
        buf[nr++] = fifo_get(&fifo);
    }
    lock_release(&lock);
    return count;
}

先看下键盘中断的修改。键盘每触发一次中断直接将字符写入队列。并且如果此时有任务在等待获取键盘输入,就将它唤醒。相当于通知它有键盘输入进来了。还实现了一个keyboard_read函数用于从队列读取字符。这个函数是不支持并发的,队列属于共享资源读取时需要上锁。

五、键盘读取测试

我们修改init任务,让它读取键盘。代码如下:

void init_thread()
{
    set_interrupt_state(true);
    u32 counter = 0;

    char ch;
    while (true)
    {
        bool intr = interrupt_disable();
        keyboard_read(&ch, 1);
        printk("%c", ch);

        set_interrupt_state(intr);
    }
}

如上所示,当内核启动进入init线程后,因为keyboard_read函数的执行需要保证不能被中断,因此init进入后先做了中断关闭。我们来梳理下运行流程。

  • init进程启动,关闭中断进入读取键盘输入
  • 此时键盘缓冲区没有数据,阻塞进行等待。
  • 当我们敲击键盘输入数据后,数据进入队列并环境init线程
  • init线程去队列读取输入的输入并打印到控制台。

此时你是否好奇,既然init线程关了中断,那为何键盘中断可以执行。这是因为当缓冲区没数据时,keyboard_read函数中将init线程阻塞,此时切换到了idle线程,idle线程开启了中断等待键盘中断的到来。键盘中断到来并执行后唤醒了init线程,init线程继续读取数据。

「真诚赞赏,手留余香」

WangFuJie Blog

真诚赞赏,手留余香

使用微信扫描二维码完成支付