Adding interrupt handling to the bare metal x86 code

In the previous recipe, we displayed the "hello world" program on bare metal. Next, we'll add interrupt handling and enable keyboard support.

Getting ready

Get the code from the last recipe and ensure that it still compiles and runs. We will be adding to it here.

How to do it…

Execute the following steps to add interrupt handling to bare metal x86 code:

  1. Declare the constants and data structures needed to communicate with the hardware. The definitions can be translated from the hardware manuals.
  2. Write an empty interrupt handler with a naked inline assembly that does nothing except return with the iretd instruction.
  3. Write a keyboard interrupt handler with a naked inline assembly. We'll need to read the byte using the keyboard from I/O port 0x60 using the in assembly instruction. Then, store it in a global variable, acknowledge the interrupt, then clean up. (The stack and all registers must be left in the same state they were in when we entered this function.) Then, resume normal execution with the iretd instruction. It is very important that you do not use the return keyword in D because interrupt handlers must return with the special instruction.
  4. Declare the __gshared variables to hold the data tables for interrupt handlers and memory protection data.
  5. Write helper functions to set the hardware data structures. Load the empty interrupt handler in the whole table and set the keyboard handler for IRQ 1, which is located at the interrupt number 1 + IRQOffset.
  6. Prepare a global descriptor table with separate descriptors for code, data, and a task state selector. We don't care about the specifics, so we'll just load the entire memory space; however, we do need to know the descriptor numbers for the interrupt handler.
  7. Load the global descriptor table with the lgdt assembly instruction.
  8. Load the interrupt descriptor table with the lidt assembly instruction.
  9. Enable IRQ1—the keyboard's interrupt request line—by writing the appropriate mask to the programmable interrupt controller's data I/O port with the inline assembly.
  10. Enable interrupts with the sti assembly function.
  11. In your main function, write an endless event loop. It should ensure interrupts are enabled with the sti instruction, then use the hlt instruction to stop the CPU, and then wait for the next interrupt. After one arrives, temporarily disable interrupts with cli so that we can handle the data without it being overwritten as we work, write a message if a key was pressed, then start the loop over again.

This is quite a bit of code, but once you get the keyboard working, adding other interrupt handlers is relatively easy. The following is the code:

// The following values are defined by the hardware specs.

enum PIC1 = 0x20; /* IO port for master PIC */
enum PIC2 = 0xA0; /* IO port for slave PIC */
enum PIC1_COMMAND = PIC1; /* Commands to PIC1 */
enum PIC1_DATA = (PIC1+1); /* IO port for data to PIC1 */
enum PIC2_COMMAND = PIC2; // ditto for PIC2
enum PIC2_DATA = (PIC2+1);

// This is the interrupt number where the IRQs are mapped.
// 8 is the default set by the system at boot up. We can
// also change this by reprogramming the interrupt controller,
// which is recommended to avoid conflicts in real world code,
// but here, for simplicity, we'll just use the default.
enum IRQOffset = 8;

// This is a data structure defined by the hardware to hold
// an interrupt handler.
align(1)
struct IdtEntry {
  align(1):
  ushort offsetLowWord;
  ushort selector;
  ubyte reserved;
  ubyte attributes;
  ushort offsetHighWord;
}

// This is a hardware-defined structure to store the location of
// a descriptor table.
align(1)
struct DtLocation {
  align(1):
  ushort size;
  void* address;
}
// Ensure the hardware structure is sized correctly.
static assert(DtLocation.sizeof == 6);

// Hardware defined data structure for holding
// the Global Descriptor Table which holds
// memory protection information.
align(1)
struct GdtEntry {
  align(1):
  ushort limit;
  ushort baseLow;
  ubyte baseMiddle;
  ubyte access;
  ubyte limitAndFlags;
  ubyte baseHigh;
}
/* End of hardware data definitions */

// This is an interrupt handler that simply returns.
void nullIh() {
  asm {
    naked;
    iretd;
  }
}

// stores the last key we got
__gshared uint gotKey = 0;

// This is a keyboard interrupt handler
void keyboardHandler() {
  asm {
    naked; // we need complete control
    push EAX; // store our scratch register

    xor EAX, EAX; // clear the scratch register
    in AL, 0x60; // read the keyboard byte

    mov [gotKey], EAX; // store what we got for use later

    // acknowledge that we handled the interrupt
    mov AL, 0x20;
    out 0x20, AL;
    pop EAX; // restore our scratch register
    iretd; // required special return instruction
  }
}

// Now, we define some data buffers to hold our tables
// that the hardware will use.

// This global array holds our entire interrupt handler table.
__gshared IdtEntry[256] idt;
__gshared GdtEntry[4] gdt; // memory protection tables
__gshared ubyte[0x64] tss; // task state data

// Now, we'll declare helper functions to load this data.

/// Enables interrupt requests.
/// lowest bit set = irq 0 enabled
void enableIrqs(ushort enabled) {
  // the hardware actually expects a disable bitmask
  // rather than an enabled list/ so we'll flip the bits.
  enabled = ~enabled;
  asm {
    // tell the interrupt controller to unmask the interrupt requests, enabling them.
    mov AX, enabled;
    out PIC1_DATA, AL;
    mov AL, AH;
    out PIC2_DATA, AL;
  }
}

// The interrupt handler structure has a strange layout
// due to backward compatibility requirements on the
// processor. This helper function helps us set it from
// a regular pointer.
void setInterruptHandler(ubyte number, void* handler) {
  IdtEntry entry;
  entry.offsetLowWord = cast(size_t)handler & 0xffff;
  entry.offsetHighWord = cast(size_t)handler >> 16;
  entry.attributes = 0x80 /* present */ |  0b1110 /* 32 bit interrupt gate */;
  entry.selector = 8;
  idt[number] = entry;
}
// Tell the processor to load our new table.
void loadIdt() {
  DtLocation loc;

  static assert(idt.sizeof == 8 * 256);

  loc.size = cast(ushort) (idt.sizeof - 1);
  loc.address = &idt;

  asm {
    lidt [loc];
  }
}

// Load a global descriptor table
//
// We don't actually care about memory protection or
// virtual memory, so we load a simple table here that
// covers the entire address space for both code and data.
void loadGdt() {
  GdtEntry entry;
  entry.limit = 0xffff;

  // page granularity and 32 bit
  entry.limitAndFlags = 0xc0 | 0x0f;
  entry.access = 0x9a; // present kernel code
  gdt[1] = entry;
  entry.access = 0x92; // data
  gdt[2] = entry;

  auto tssPtr = cast(size_t) &tss;
  entry.baseLow = tssPtr & 0xffff;
  entry.baseMiddle = (tssPtr >> 16) & 0xff;
  entry.baseHigh = tssPtr >> 24;
  entry.limit = tss.sizeof;
  entry.limitAndFlags = 0x40; // present

  entry.access = 0x89; // a task buffer (we don't use it but do prepare it)
  gdt[3] = entry;

  DtLocation loc;
  loc.size = cast(ushort) (gdt.sizeof - 1);
  loc.address = &gdt;

  asm {
    lgdt [loc]; // tell the processor to load it
  }
}
// This function initializes the system and enables interrupts,
// setting all handlers, except the keyboard IRQ, to our null
// handler. We'll call it from main.
void initializeSystem() {
  loadGdt();

  foreach(i; 0 .. 256)
    setInterruptHandler(cast(ubyte) i, &nullIh);

  setInterruptHandler(IRQOffset + 1, &keyboardHandler);
  loadIdt();

  enableIrqs(1 << 1 /* IRQ1 enabled: keyboard */);

  asm {
    sti; // enable interrupts
  }
}

And here is our new main function that watches for the keyboard input:

void main() {
  clearScreen();
  //print("Hello, world, from bare metal!");
  initializeSystem();

  while(true) {
    asm {
      sti; // enable interrupts
      hlt; // halt the CPU until an interrupt arrives
      cli; // disable interrupts while we handle the data
    }

    if(gotKey) {
      print("We got a key!");
      // Change the color of the W so we can see a
      // difference on each key press
      ubyte* videoMemory = cast(ubyte*) 0xb8000;
      videoMemory[1] += 1;

      // clear out the buffer
      gotKey = 0;
    }
  }
}

You may factor out the helper functions and hardware structure definitions to a separate file if you want. Build the program with the same make file as before, then load it with qemu –kernel my_kernel in the same way. When it loads, the screen will clear. Press any key and you'll see a message.

How it works…

Interrupt handlers have specific requirements by the hardware. They must leave the stack and all registers in the same state they were found when the method was entered, and unlike normal methods, they must return with the iret family of instructions (specifically, iretd on 32-bit) instead of the regular ret instruction.

D has two important features that help with these requirements without requiring us to write a helper file with a separate assembler: inline assembly and naked functions.

A naked function is a block of code with no instructions generated by the compiler. It neither prepares a stack frame nor automatically returns at the end of its execution.

Tip

If you want to create a stack frame manually, for example, to use local variables, you may use the __LOCAL_SIZE constant provided by the compiler to allocate enough space for your locals.

The other part of complete control is writing the entire interrupt handler in the assembly language, which is made easy thanks to the easy accessibility of D's variables. While we could have used D itself, even in a naked function, that would require us to store more registers on the stack to ensure they are preserved correctly. This is doable, but it is unnecessary here because the interrupt handler needs to stay short anyway.

The keyboard handler performs three tasks. First, it reads the byte from the keyboard's I/O port with in AL, 0x60;. Then, it stores that value in a global variable for later reference and acknowledges the interrupt by writing 0x20, the magic number defined by the hardware for acknowledgements, to the programmable interrupt controller's control port. If you forget to acknowledge an interrupt, you won't get any more—the first key press would work, but then all others wouldn't.

Finally, it returns with the special iretd instruction after carefully restoring the register we used and cleaning up the stack.

Tip

Be careful not to use iret instead of iretd. dmd will output the 16-bit opcode instead of the 32-bit opcode we need, causing the processor to fault and reset the computer.

The event loop in main is currently written for simplicity and can be improved if written more concurrently and to perform other tasks while waiting. The basic idea is to wait for an interrupt by first ensuring they are enabled with sti and then stopping the CPU until one arrives with hlt.

Once it arrives, we basically assert a global lock by disabling interrupts with cli, ensuring our data won't be changed mid-read (an interrupt can occur after any CPU instruction), and check for new data. If we have a key, we display our message, update the color of the first character cell to see the change on each event, and reset the buffer for the next event.

Note

We write to videoMemory, a pointer, with the indexing operator such as an array. D allows this in the same way as C, with no bounds checking at all.

A better implementation may provide a longer keyboard buffer and shorter lock if it locks at all. After installing an interrupt handler for the system clock and running a task while not doing anything else (although still using hlt if there are no tasks to be run), we'd have the beginning of a real operating system kernel.

The rest of the code prepares the hardware to enable our interrupt handler. Although it's necessarily bulky, it doesn't utilize many D features. One notable aspect, however, is the struct alignment and the static assert to confirm that the size is correct—this is the real-world application of some code we wrote in Chapter 7, Correctness Checking. In this case, the outer align isn't strictly necessary for the code to work, but generally, when going for a specific size, both aligns are necessary for the size check to pass: the one outside to remove struct padding and the one inside to remove field padding. If we forgot one or the other, there may be unused space in the struct that would confuse the hardware. Better to have strict tests that pass reliably than depend on implementation details of struct padding.

Also note that all the global variables are marked with __gshared. D's module-level and static variables are put in thread-local storage by default. Since our operating system has no threads, it also does not support thread-local storage! If we forgot __gshared, the code would still compile, but it would corrupt the memory and likely crash our system when we tried to run it. In the bare metal environment, the guideline against __gshared doesn't apply—here, you should use it or the shared keyword, unless you know you have correctly implemented TLS.

Tip

The shared keyword has another potential use on bare metal: it can make up for the lack of a volatile statement in D. Since the compiler knows shared data may be updated by somebody else (another thread in most cases, but it could also be updated by other hardware), it treats shared reads and writes as volatile. Another alternative is to always perform volatile input and output with inline assembly or functions written in assembly.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.217.198