
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 to
110
as we are defining an interrupt gate. - D. 1 bit indicating whether the code segment is 32 bit. Will be set to
1
. - DPL. 2 bits The descriptor privilege level indicates what privilege is required to invoke the handler. Will be set to
00
. - P. 1 bit indicating whether the gate is active. Will be set to
1
. - 0. Some bits that always need to be set to
0
for interrupt gates.
The diagram below illustrates the layout of an IDT gate.
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;
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);}
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();}
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;
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
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
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);}
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 to
0x20
(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 set
0x01
=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);}
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);}
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
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];
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}}
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;
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));}
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();}
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;}}
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();}
#define IRQ1 33voidinit_keyboard(){register_interrupt_handler(IRQ1,keyboard_callback);}
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();}
Now let's boot and type something...
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)

- WorkLead PE / SRE at DataStax, an IBM Company
- Joined
Keyboardception. Not as impressive as writing the C compiler in C, though :D

- WorkLead PE / SRE at DataStax, an IBM Company
- Joined

- LocationAraçatuba-SP, Brasil
- EducationUnisalesiano
- WorkSoftware Engineer at KaBuM!
- Joined
Well, afeter i implement this step , my screen of the qemu is blink:
For further actions, you may consider blocking this person and/orreporting abuse