在我之前的文章让我们编写一个内核,讲述了如何搭建一个初级的x86内核,该内核使用GRUB启动,运行于保护模式,能在屏幕上输出字符串。

今天,我将为该内核添加键盘驱动,这样它能够获取键盘上a-z和0-9字符输入并且输出到屏幕。

本文使用的源代码可以在我的GirHub仓库-mkeykernel中获取。

我们使用I/O端口来与I/O设备交互。这些端口只是x86 I/O总线上的具体地址,仅此而已。对该端口的读/写操作通过处理器内建的特定指令完成。

端口读写

read_port:
        mov edx, [esp + 4]
        in al, dx      
        ret

write_port:
        mov   edx, [esp + 4]    
        mov   al, [esp + 4 + 4]  
        out   dx, al  
        ret

I/O端口的访问使用x86指令集中的in和out指令。

在read_port中,端口号被视为参数。当编译器调用你的函数时,它将所有的参数压入栈中。参数使用栈指针复制到寄存器edx中。寄存器dx是edx的低16位。这里的in指令读取dx指定端口并将结果输出到al中。寄存器al是eax的低8位。如果你还记得大学课程,函数的返回值存放于eax寄存器中。因此,read_port可以读取I/O端口数据。

write_port非常类似。这里有两个参数:端口号和待写入数据。out指令将数据写入端口中。

中断

在我们开始编写任何设备驱动前,我们需要了解处理器是如何知道设备执行了一个事件。

最简单的方法是轮询—始终保持对设备状态的监测。这样显然效率太低且不实用。因此中断机制被引入。中断是硬件或软件发送给处理器的一个信号,代表一个事件。使用中断,我们可以避免轮询,仅当我们关注的指定中断触发时才响应。

可编程中断控制器(PIC)器件或芯片确保x86成为支持中断驱动的架构。该器件或芯片负责管理硬件中断并将中断发送至相应系统中断。

当硬件设备上执行指定操作时,它会向与PIC芯片连接的指定中断引脚上发送一个中断请求(IRQ)脉冲。接着,PIC将接收到的IRQ转换成系统中断,并发送消息中止CPU当前执行的任何工作。接下来,由内核负责处理这些中断。

如果没有PIC,我们就必须轮询系统中的所有设备来查看它们中是否有事件发生。

下面以键盘为例。键盘依靠0×60和0×64 I/O端口工作。端口0×60输出数据(按了什么键),端口0×64输出状态。但是,你必须确切知道什么时候去读取这些端口。

此处使用中断很简单。当按下一个键时,键盘会在IRQ1中断线上发送一个信号给PIC。PIC在初始化期间存储了一个偏移量。该设备将输入线号叠加偏移量就形成中断号。接着,处理器查询称为中断描述符表(IDT)的特定数据结构给出此中断号相对应的中断处理程序入口地址。

接着,上述地址的事件处理代码会被执行。

建立IDT

struct IDT_entry{
	unsigned short int offset_lowerbits;
	unsigned short int selector;
	unsigned char zero;
	unsigned char type_attr;
	unsigned short int offset_higherbits;
};

struct IDT_entry IDT[IDT_SIZE];

void idt_init(void)
{
	unsigned long keyboard_address;
	unsigned long idt_address;
	unsigned long idt_ptr[2];

	/* populate IDT entry of keyboard's interrupt */
	keyboard_address = (unsigned long)keyboard_handler; 
	IDT[0x21].offset_lowerbits = keyboard_address & 0xffff;
	IDT[0x21].selector = 0x08; /* KERNEL_CODE_SEGMENT_OFFSET */
	IDT[0x21].zero = 0;
	IDT[0x21].type_attr = 0x8e; /* INTERRUPT_GATE */
	IDT[0x21].offset_higherbits = (keyboard_address & 0xffff0000) >> 16;

	/*     Ports
	*	 PIC1	PIC2
	*Command 0x20	0xA0
	*Data	 0x21	0xA1
	*/

	/* ICW1 - begin initialization */
	write_port(0x20 , 0x11);
	write_port(0xA0 , 0x11);

	/* ICW2 - remap offset address of IDT */
	/*
	* In x86 protected mode, we have to remap the PICs beyond 0x20 because
	* Intel have designated the first 32 interrupts as "reserved" for cpu exceptions
	*/
	write_port(0x21 , 0x20);
	write_port(0xA1 , 0x28);

	/* ICW3 - setup cascading */
	write_port(0x21 , 0x00);  
	write_port(0xA1 , 0x00);  

	/* ICW4 - environment info */
	write_port(0x21 , 0x01);
	write_port(0xA1 , 0x01);
	/* Initialization finished */

	/* mask interrupts */
	write_port(0x21 , 0xff);
	write_port(0xA1 , 0xff);

	/* fill the IDT descriptor */
	idt_address = (unsigned long)IDT ;
	idt_ptr[0] = (sizeof (struct IDT_entry) * IDT_SIZE) + ((idt_address & 0xffff) << 16);
	idt_ptr[1] = idt_address >> 16 ;

	load_idt(idt_ptr);
}

IDT依靠IDT_entry组成的结构体数组实现。文章稍后会讨论键盘中断是如何映射到中断处理程序。首先,我们来了解PIC是如何工作的。

现在的x86系统有2块PIC芯片,每一块有8条输入线。我们称其为PIC1和PIC2。PIC1接收IRQ0到IRQ7,PIC2接收IRQ8至IRQ15。PIC1使用0×20作为命令端口,0×21作为数据。PIC2使用0xA0作为命令端口,0xA1作为数据端口。

PIC初始化使用8位命令字,该命令字叫做初始化命令字(ICW)。这些命令字的具体每一位语法参考此链接

在保护模式下,你需要发送给这两个PIC的第一条命令是初始化命令ICW1(0×11)。该指令让PIC等待数据端口的其他3条初始化字。

这些指令告诉PIC以下信息:

*它的偏移向量。(ICW2)

*PIC是如何作为主/从设备的。(ICW3)

*给出环境相关的附加信息。(ICW4)

第二条初始化指令是ICW2,该指令会被写入每一个PIC的数据端口。该指令设置PIC的偏移量。该偏移量和输入线的数据相加可以得到中断号。

PIC允许彼此之间输出到输入的级联。级联的建立使用ICW3,每一位代表相应IRQ的级联状态。至于现在,我们不使用级联并且所有位均设为0。

ICW4设置附加环境参数。我们仅设置大部分低位来告诉PIC我们运行于80×86模式。

Tang ta dang!! PIC初始化完成。

每一个PIC内部有一个8位寄存器叫做中断屏蔽寄存器(IMR)。该寄存器中存放进入PIC的IRQ线的位图。当一位被置位时,PIC会忽略相应的请求。这意味着我们可以通过设置IMR中的数值的第n位为0或1来启用和禁止第n跳IRQ线。从数据端口读取的数据返回值存放在IMR寄存器中,向其中写入可设置寄存器。此处我们的代码中,在PIC初始化后,我们设置所有位为1,因此所有IRQ线都被禁用。我们稍后会启用键盘中断对应的线。至于现在,我们禁用所有的中断!!

假设IRQ线开启,PIC可以通过IRQ线接收信号,并叠加偏移量转换成中断号。现在,我们需要填写IDT,这样键盘的中断号才能映射到我们编写的键盘处理程序的地址。

那么键盘处理函数地址应该与IDT中哪一个中断号映射?

键盘使用IRQ1。IRQ1是PIC1的输入线。我们已经将PIC1的偏移量初始化为0×20(参见ICW2)。为查找中断号,将1加上偏移量0×20,即0×21。所以在IDT中,键盘终端处理程序地址必须映射到0×21号中断。

那么,接下来的任务就是填写IDT中的0×21中断。

我们要将该中断映射到我们在汇编文件中编写的键盘处理函数。

每一个IDT条目由64位组成。在IDT中断条目中,我们没有将中断处理程序地址作为一个整体存储。我们把它分成两个16位部分。低16位存储在IDT条目的前16位,高16位存储在IDT条目的最后16位。这样做的目的是为了保持与286兼容。你可以在很多地方看到Intel类似的巧妙设计!!

在IDT条目中,我们还必须设置类型——这样做是为了捕获中断。我们还需给出内核代码段偏移量。GRUB引导为我们建立一个GDT。每一项GDT条目占8个字节,内核代码描述符是第二个段;所以它的偏移量是0×08(更多相关描述对本文过于冗余)。中断门由0x8e表示。中间剩余8位必须全部填充为0。这样,我们就填充了与键盘中断相对应的IDT条目。

一旦IDT中需要映射完成,我们需要告诉CPU IDT的位置。

该操作通过lidt汇编指令完成。lidt指令有一个操作数。该操作数必须是一个指向描述IDT描述符结构的指针。

该描述符十分简单。它包含了IDT的字节空间和地址。我使用了一个数组来打包该数值。你也可以使用一个结构体来填写该数值。

我们在变量idt_ptr中存放了指针,然后使用load_idt()函数传递指针给lidt指令。

load_idt:
	mov edx, [esp + 4]
	lidt [edx]
	sti
	ret

此外,load_idt()函数使用sti指令启用中断。

一旦IDT被建立并加载,我们可以使用前面讨论过的中断屏蔽启用键盘的IRQ线。

void kb_init(void)
{
	/* 0xFD is 11111101 - enables only IRQ1 (keyboard)*/
	write_port(0x21 , 0xFD);
}

键盘终端处理函数

既然我们已经通过IDT的0×21中断条目成功将键盘中断映射到键盘处理函数。

那么,每次你按下键盘上的一个键,键盘处理函数必定会被调用。

keyboard_handler:                 
	call    keyboard_handler_main
	iretd

键盘处理函数仅仅是调用了另一个C函数并使用iret类指令返回。我们本可以在此处编写整个中断处理程序,但是相对汇编而言编写C代码要更容易——所以这里采用函数调用。

当从中断处理程序返回中断之前的程序时,应该使用iret/iretd替换ret指令。这些指令会将中断调用前入栈的标志寄存器值出栈。

void keyboard_handler_main(void) {
	unsigned char status;
	char keycode;

	/* write EOI */
	write_port(0x20, 0x20);

	status = read_port(KEYBOARD_STATUS_PORT);
	/* Lowest bit of status will be set if buffer is not empty */
	if (status & 0x01) {
		keycode = read_port(KEYBOARD_DATA_PORT);
		if(keycode < 0)
			return;
		vidptr[current_loc++] = keyboard_map[keycode];
		vidptr[current_loc++] = 0x07;	
	}
}

首先,我们通过向PIC通用端口写入指令发送EOI信号。只有这样,PIC才允许更多的中断请求。这里我们必须读取两个端口——0×60数据端口和0×64命令/状态端口。

我们首先读取0×64端口获取状态。如果状态的最低位是0,这意味着缓冲区是空的,没有可读取数据。否则,我们要读取0×60数据端口。该端口将给出我们按下的键盘键码。每一个键码对应于键盘上的一个按键。keyboard_map.h头中定义了一个简单的字符数组来完成键码到对应字符的映射。该字符在屏幕上的输出与之前那篇文章中使用的技术相同。

在本篇文章中,为了简洁,我仅仅处理了小写字母a-z和数字0-9。你可以轻松扩展到其他特殊字符,ALT,SHIFT,CAPS LOCK。你可以根据状态端口输出知道按键是否被按下或释放,并执行所需操作。你也可以将任意按键组合映射到特定功能,例如关机等。

你可以编译内核,在实际机器或虚拟机(QEMU)上运行,和之前的文章(内核仓库)一样。

开始输入!!

1 1 收藏 评论

关于作者:ashiontang

(新浪微博:@ashiontang) 个人主页 · 我的文章 · 10