Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Writing My Own Keyboard Driver
Frank Rosner
Frank Rosner

Posted on • Edited on

     

Writing My Own Keyboard Driver

Introduction

In the previous post we implemented a video driver so that we are able to print text on the screen. For an operating system to be useful to the user however, we also want them to be able to input commands. Text input and output will be the foundation for future shell functionality.

But how does the communication between the keyboard and our operating system work? The keyboard is connected to the computer through a physical port (e.g. serial, PS/2, USB). In case of PS/2 the data is received by a microcontroller which is located on the motherboard. When a key is pressed, the microcontroller stores the relevant information inside the I/O port0x60 and sends an interrupt requestIRQ 1 to the programmable interrupt controller (PIC).

The PIC then interrupts the CPU with a predefined interrupt number based on the external IRQ. On receiving the interrupt, the CPU will consult the interrupt descriptor table (IDT) to look up the respective interrupt handler it should invoke. After the handler has completed its task, the CPU will resume regular execution from before the interrupt.

For the complete chain to work we need to do some preparations during the kernel initialization. First, we have to setup the correct mapping inside the PIC so that our IRQs get translated to actual interrupts correctly. Then, we must create and load a valid IDT that contains a reference to our keyboard handler. The handler then reads all relevant data from the respective I/O ports and converts it to text that we can show to the user, such asLCTRL orA.

Now that we know the high level overview of what we need to do, let's jump into it! The remainder of this post is structured as follows. The next section focuses on defining and loading the IDT. Afterwards we will implement the keyboard interrupt handler and register it. Last but not least we extend the kernel functionality to execute the newly written code in the correct order.

Thesource code is available at GitHub. The code examples of this post use type aliases from#include <stdint.h> which are a bit more structured than the original C types.uint16_t corresponds to an unsigned 2 byte (16 bit) value, for example.

Setting Up The IDT

IDT Structure

The IDT consists of 256 descriptor entries, called gates. Each of those gates is 8 bytes long and corresponds to exactly one interrupt number, determined from its position in the table. There are three types of gates: task gates, interrupt gates, and trap gates. Interrupt and trap gates can invoke custom handler functions, with interrupt gates temporarily disabling hardware interrupt handling during the handler invocation, which makes it useful for processing hardware interrupts. Task gates cause allow using the hardware task switch mechanism to pass control of the processor to another program.

We only need to define interrupt gates for now. An interrupt gate contains the following information:

  • Offset. The 32 bit offset represents the memory address of the interrupt handler within the respective code segment.
  • Selector. The 16 bit selector of the code segment to jump to when invoking the handler. This will be our kernel code segment.
  • Type. 3 bits indicating the gate type. Will be set to110 as we are defining an interrupt gate.
  • D. 1 bit indicating whether the code segment is 32 bit. Will be set to1.
  • DPL. 2 bits The descriptor privilege level indicates what privilege is required to invoke the handler. Will be set to00.
  • P. 1 bit indicating whether the gate is active. Will be set to1.
  • 0. Some bits that always need to be set to0 for interrupt gates.

The diagram below illustrates the layout of an IDT gate.

IDT gate structure

To create an IDT gate in C, we first define theidt_gate_t struct type.__attribute__((packed)) tellsgcc to pack the data inside the struct as tight as they are defined. Otherwise the compiler might include padding to optimize the struct layout with respect to the CPU cache size, for example.

typedefstruct{uint16_tlow_offset;uint16_tselector;uint8_talways0;uint8_tflags;uint16_thigh_offset;}__attribute__((packed))idt_gate_t;
Enter fullscreen modeExit fullscreen mode

Now we can define our IDT as an array of 256 gates and implement a setter functionset_idt_gate to register ahandler for interruptn. We will make use of two small helper functions to split the 32 bit memory address of the handler.

#define low_16(address) (uint16_t)((address) & 0xFFFF)#define high_16(address) (uint16_t)(((address) >> 16) & 0xFFFF)idt_gate_tidt[256];voidset_idt_gate(intn,uint32_thandler){idt[n].low_offset=low_16(handler);idt[n].selector=0x08;// see GDTidt[n].always0=0;// 0x8E = 1  00 0 1  110//        P DPL 0 D Typeidt[n].flags=0x8E;idt[n].high_offset=high_16(handler);}
Enter fullscreen modeExit fullscreen mode

Setting Up Internal ISRs

An interrupt handler is also referred to as a interrupt service routines (ISR). The first 32 ISRs are reserved for CPU specific interrupts, such as exceptions and faults. Setting these up is crucial as they are the only way for us to know if we are doing something wrong when remapping the PIC and defining the IRQs later. You can find a full list either in the source code or onWikipedia.

First, we define a generic ISR handler function in C. It can extract all necessary information related to the interrupt and act accordingly. For now we will have a simple lookup array that contains a string representation for each interrupt number.

char*exception_messages[]={"Division by zero","Debug",\\..."Reserved"};voidisr_handler(registers_t*r){print_string(exception_messages[r->int_no]);print_nl();}
Enter fullscreen modeExit fullscreen mode

To make sure we have all information available, we are going to pass a struct of typeregisters_t to the function that is defined as follows:

typedefstruct{// data segment selectoruint32_tds;// general purpose registers pushed by pushauint32_tedi,esi,ebp,esp,ebx,edx,ecx,eax;// pushed by isr procedureuint32_tint_no,err_code;// pushed by CPU automaticallyuint32_teip,cs,eflags,useresp,ss;}registers_t;
Enter fullscreen modeExit fullscreen mode

The reason this struct is so complex lies in the fact that we are going to invoke the handler function (which is written in C) from within assembly. Before a function is invoked, C expects the arguments to be present on the stack. The stack will contain some information already and we are extending it with additional information.

Below is an excerpt of the assembly code that defines the first 32 ISRs. Unfortunately there is no way to know which gate was used to invoke the handler so we need one handler for each gate. We have to define the labels asglobal so that we can reference them from our C code later.

global isr0global isr1; ...global isr31; 0: Divide By Zero Exceptionisr0:    push byte 0    push byte 0    jmp isr_common_stub; 1: Debug Exceptionisr1:    push byte 0    push byte 1    jmp isr_common_stub; ...; 12: Stack Fault Exceptionisr12:    ; error info pushed by CPU    push byte 12    jmp isr_common_stub; ...; 31: Reservedisr31:    push byte 0    push byte 31    jmp isr_common_stub
Enter fullscreen modeExit fullscreen mode

Each procedure makes sure thatint_no anderr_code are on the stack before handing over to the common ISR procedure, which we will look at in a moment. The first push (err_code), if present, represents error information that is specific to certain exceptions like stack faults. If such an exception occurs, the CPU will push this error information to the stack for us. To have a consistent stack for all ISRs, we are pushing a0 byte in the cases where no error information is available. The second push corresponds to the interrupt number.

Now let's look at the common ISR procedure. It will fill the stack with all information required forregisters_t, prepare the segment pointers to invoke our kernel ISR handlerisr_handler, push the stack pointer (which is a pointer toregisters_t actually) to the stack, callisr_handler, and clean up afterwards so that the CPU can resume where it was interrupted.isr_handler has to be marked asextern, because it will be defined in 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 modeExit fullscreen mode

Last but not least, we can register the first 32 ISRs in our IDT using theset_idt_gate function from before. We are wrapping all the invocations insideisr_install.

voidisr_install(){set_idt_gate(0,(uint32_t)isr0);set_idt_gate(1,(uint32_t)isr1);// ...set_idt_gate(31,(uint32_t)isr31);}
Enter fullscreen modeExit fullscreen mode

Now that we have the CPU internal interrupt handlers in place, we can move to remapping the PIC and setting up the IRQ handlers.

Remapping the PIC

In our x86 system, the8259 PIC is responsible for managing hardware interrupts. Note that an updated standard, the advanced programmable interrupt controller (APIC), exists for modern computers but this is beyond the scope of this post. We will utilize a cascade of two PICs, whereas each of them can handle 8 different IRQs. The secondary chip is connected to the primary chip through an IRQ, effectively giving us 15 different IRQs to handle.

The BIOS programs the PIC with reasonable default values for the 16 bit real mode, where the first 8 IRQs are mapped to the first 8 gates in the IDT. In protected mode however, these conflict with the first 32 gates that are reserved for CPU internal interrupts. Thus, we need to reprogram (remap) the PIC to avoid conflicts.

Programming the PIC can be done by accessing the respective I/O ports. The primary PIC uses ports0x20 (command) and0x21 (data). The secondary PIC uses ports0xA0 (command) and0xA1 (data). The programming happens by sending four initialization command words (ICWs). If the following paragraphs are confusing, I recommend reading this comprehensivedocumentation.

First, we have to send the initialize command ICW1 (0x11) to both PICs. They will then wait for the following three inputs on the data ports:

  • ICW2 (IDT offset). Will be set to0x20 (32) for the primary and0x28 (40) for the secondary PIC.
  • ICW3 (wiring between PICs). We will tell the primary PIC to accept IRQs from the secondary PIC on IRQ 2 (0x04, which is0b00000100). The secondary PIC will be marked as secondary by setting0x02 =0b00000010.
  • ICW4 (mode). We set0x01 =0b00000001 in order to enable 8086 mode.

We finally send the first operational command word (OCW1)0x00 =0b00000000 to enable all IRQs (no masking). Equipped with theport_byte_out function from the previous post we can extendisr_install to perform the PIC remapping as follows.

voidisr_install(){// internal ISRs// ...// ICW1port_byte_out(0x20,0x11);port_byte_out(0xA0,0x11);// ICW2port_byte_out(0x21,0x20);port_byte_out(0xA1,0x28);// ICW3port_byte_out(0x21,0x04);port_byte_out(0xA1,0x02);// ICW4port_byte_out(0x21,0x01);port_byte_out(0xA1,0x01);// OCW1port_byte_out(0x21,0x0);port_byte_out(0xA1,0x0);}
Enter fullscreen modeExit fullscreen mode

Now that we successfully remapped the PIC to send IRQs to the interrupt gates 32-47 we can register the respective ISRs.

Setting Up IRQ Handlers

Adding the ISRs to handle IRQs is very similar to the first 32 CPU internal ISRs we created. First, we extend the IDT by adding gates for our IRQs 0-15.

voidisr_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 modeExit fullscreen mode

Then, we add the IRQ procedure labels to our assembler code. We are pushing the IRQ number as well as the interrupt number to the stack before calling theirq_common_stub.

global irq0; ...global irq15irq0:    push byte 0    push byte 32    jmp irq_common_stub; ...irq15:    push byte 15    push byte 47    jmp irq_common_stub
Enter fullscreen modeExit fullscreen mode

irq_common_stub is defined analogous to theisr_common_stub and it will call a C the functionirq_handler. The IRQ handler will be defined a bit more modular though, as we want to be able to add individual handlers dynamically when loading the kernel, such as our keyboard handler. To do that we initialize an array of interrupt handlersisr_t which are functions that take the previously definedregisters_t.

typedefvoid(*isr_t)(registers_t*);isr_tinterrupt_handlers[256];
Enter fullscreen modeExit fullscreen mode

Based on that we can write our general purposeirq_handler. It will retrieve the respective handler from the array based on the interrupt number and invoke it with the givenregisters_t. Note that due to the PIC protocol we must send an end of interrupt (EOI) command to the involved PICs (only primary for IRQ 0-7, both for IRQ 8-15). This is required for the PIC to know that the interrupt is handled and it can send further interrupts. Here goes the code:

voidirq_handler(registers_t*r){if(interrupt_handlers[r->int_no]!=0){isr_thandler=interrupt_handlers[r->int_no];handler(r);}port_byte_out(0x20,0x20);// primary EOIif(r->int_no<40){port_byte_out(0xA0,0x20);// secondary EOI}}
Enter fullscreen modeExit fullscreen mode

Now we are almost done. The IDT is defined and we only need to tell the CPU to load it.

Loading the IDT

The IDT can be loaded using thelidt instruction. To be precise,lidt does not load the IDT but instead an IDT descriptor. The IDT descriptor contains the size (limit in bytes) and the base address of the IDT. We can model the descriptor as a struct like so:

typedefstruct{uint16_tlimit;uint32_tbase;}__attribute__((packed))idt_register_t;
Enter fullscreen modeExit fullscreen mode

We can then calllidt inside a new function calledload_idt. It sets the base by obtaining the pointer to theidt gate array and computes the memory limit by multiplying the number of IDT gates (256) with the size of each gate. As usual, the limit is the size - 1.

idt_register_tidt_reg;voidload_idt(){idt_reg.base=(uint32_t)&idt;idt_reg.limit=IDT_ENTRIES*sizeof(idt_gate_t)-1;asmvolatile("lidt (%0)"::"r"(&idt_reg));}
Enter fullscreen modeExit fullscreen mode

And here goes the final modification of ourisr_install function, loading the IDT after we installed all ISRs.

voidisr_install(){// internal ISRs// ...// PIC remapping// ...// IRQ ISRs// ...load_idt();}
Enter fullscreen modeExit fullscreen mode

This concludes the IDT section of this post and we can finally move to keyboard specific code. It is supposed to be a blog post about a keyboard driver after all, am I right?

Keyboard Handler

When a key is pressed, we need a way to identify which key it was. This can be done by reading thescan code of the respective keys. Note that the scan codes distinguish between a key being pressed (down) or being released (up). The scan code for releasing a key can be calculated by adding0x80 to the respective key down code.

Aswitch statement contains all key down scan codes we want to handle right now. If a scan code does not match any of those cases, this can have 3 reasons. Either it is an unknown key down, or a released key. If the released key is within our expected range, we simply subtract0x80 from the code. We can put this logic into aprint_letter function:

voidprint_letter(uint8_tscancode){switch(scancode){case0x0:print_string("ERROR");break;case0x1:print_string("ESC");break;case0x2:print_string("1");break;case0x3:print_string("2");break;// ...case0x39:print_string("Space");break;default:if(scancode<=0x7f){print_string("Unknown key down");}elseif(scancode<=0x39+0x80){print_string("key up ");print_letter(scancode-0x80);}else{print_string("Unknown key up");}break;}}
Enter fullscreen modeExit fullscreen mode

Note that scan codes are keyboard specific. The ones above are valid for IBM PC compatible PS/2 keyboards, for example. USB keyboards use different scan codes. Next, we have to implement and register an interrupt handler function for key presses. The PIC saves the scan code in port0x60 after IRQ 1 is sent. So let's implementkeyboard_callback and register it at IRQ 1, which is mapped to interrupt number 33.

staticvoidkeyboard_callback(registers_t*regs){uint8_tscancode=port_byte_in(0x60);print_letter(scancode);print_nl();}
Enter fullscreen modeExit fullscreen mode
#define IRQ1 33voidinit_keyboard(){register_interrupt_handler(IRQ1,keyboard_callback);}
Enter fullscreen modeExit fullscreen mode

We are almost done! The only thing left to do is to modify the main kernel function.

New Kernel

The new kernel function needs to put all the pieces together. It has to install the ISRs, effectively loading our IDT. Then it will enable external interrupts by setting the interrupt flag usingsti. Finally, we can call theinit_keyboard function that registers the keyboard interrupt handler.

voidmain(){clear_screen();print_string("Installing interrupt service routines (ISRs).\n");isr_install();print_string("Enabling external interrupts.\n");asmvolatile("sti");print_string("Initializing keyboard (IRQ 1).\n");init_keyboard();}
Enter fullscreen modeExit fullscreen mode

Now let's boot and type something...

demo

Amazing! Having a VGA driver and a keyboard driver in place, we can work on a simple shell in the next post :)


Cover image byJohn Karlo Mendoza onUnsplash

If you liked this post, you cansupport me on ko-fi.

Top comments(10)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
shrihankp profile image
Shrihan
  • Location
    Somewhere in the planet called Earth
  • Joined

Writing a keyboard driver using the keyboard 😁

CollapseExpand
 
frosnerd profile image
Frank Rosner
My professional interests are cloud and big data technologies, machine learning, and software development. I like to read source code and research papers to understand how stuff works.Pronoun: He
  • Work
    Lead PE / SRE at DataStax, an IBM Company
  • Joined
• Edited on• Edited

Keyboardception. Not as impressive as writing the C compiler in C, though :D

CollapseExpand
 
shrihankp profile image
Shrihan
  • Location
    Somewhere in the planet called Earth
  • Joined
• Edited on• Edited

Oh, you mean Compilerception 😂

Thread Thread
 
frosnerd profile image
Frank Rosner
My professional interests are cloud and big data technologies, machine learning, and software development. I like to read source code and research papers to understand how stuff works.Pronoun: He
  • Work
    Lead PE / SRE at DataStax, an IBM Company
  • Joined

CollapseExpand
 
simoes profile image
Guilherme Giácomo Simões
Kernel Linux Developer | Backend Developer
  • Location
    Araçatuba-SP, Brasil
  • Education
    Unisalesiano
  • Work
    Software Engineer at KaBuM!
  • Joined

Well, afeter i implement this step , my screen of the qemu is blink:

CollapseExpand
 
anshu123code profile image
Anshu123code
  • Joined

no able to understand irq_handler code

CollapseExpand
 
wangxy01 profile image
jjboy
  • Joined

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

maybe be >=40

CollapseExpand
 
kaktuzneo profile image
Hydrogen
  • Joined

Please get a source code.

CollapseExpand
 
anshu123code profile image
Anshu123code
  • Joined

typedef void (*isr_t)(registers_t *);
wht do this line mean

CollapseExpand
 
hilcon222 profile image
hilcon222
  • Joined

it means that if i say
isr_t huh;
i mean
void (*huh)(registers_t *);

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

My professional interests are cloud and big data technologies, machine learning, and software development. I like to read source code and research papers to understand how stuff works.Pronoun: He
  • Work
    Lead PE / SRE at DataStax, an IBM Company
  • Joined

More fromFrank Rosner

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp