In the previous section of this tutorial for writing your own bootloader for a toy operating system, we looked at protected mode. In particular, we examined the global descriptor table (GDT), which is a structure the CPU uses to determine access to memory and to allow a flat memory model, rather than a segmented one like in real mode.

We analyzed the structure of the entries in the global descriptor table, and looked at some code to set up a simple GDT with three entries, which is all we need to run our kernel. In this section, we’ll look at another, but very similar structure: the interrupt descriptor table (IDT). But before that, we’ll need to see what interrupts actually are and what they are used for.

This article is part of a series on toy operating system development.

View the series index

In the previous section of this tutorial for writing your own bootloader for a toy operating system, we looked at protected mode. In particular, we examined the global descriptor table (GDT), which is a structure the CPU uses to determine access to memory and to allow a flat memory model, rather than a segmented one like in real mode.

We analyzed the structure of the entries in the global descriptor table, and looked at some code to set up a simple GDT with three entries, which is all we need to run our kernel. In this section, we’ll look at another, but very similar structure: the interrupt descriptor table (IDT). But before that, we’ll need to see what interrupts actually are and what they are used for.

This article is part of a series on toy operating system development.

View the series index

About interrupts

An interrupt is a way of talking to the operating system. When a process requires some service from the operating system, it sends an interrupt. This interrupt is received by the kernel, which performs the service, and returns control to the process. On the other hand, an interrupt can be sent by hardware. When a key is pressed on the keyboard, then the keyboard chip sends an interrupt to the operating system, so that the kernel can respond to that keypress. When the mouse moves, an interrupt is sent. When a floppy disk drive is done reading the sector is was asked to read, it sends an interrupt. And so on and on. There are, in fact, a lot of interrupts being sent to the kernel at any given time.

More correctly, an interrupt isn’t actually sent directly to the operating system. Rather, an interrupt is a way of telling the CPU that something happened or that something is required. The CPU, then, executes a bit of code that belongs to the operating system to perform the requested service.

Why is this done this way? Couldn’t a process simply call some operating system function directly? It shouldn’t, for the following reasons:

  • Pieces of hardware don’t know where the kernel lives, or how it works. After all, there are many different operating systems and they can all interface with, say, a keyboard. This is because the keyboard doesn’t know about the kernel. When a key is pressed, it simply tells the CPU about that, and the CPU lets the kernel know. That way, the only intermediary who has to know where are kernel lives, is the CPU.
  • Ordinary user processes should not know where the kernel lives. In order to make a function call, you need the memory address where the function lives, and jump to it. This is all more information than we really want to give to a user process. It’s simpler for the user process to just say, “do interrupt 25” and let the CPU handle everything else.
  • In protected mode, ordinary user processes run at a lower privilege level than the kernel. They cannot transfer control to kernel code, unless they go through the processor using an interrupt.

So, an interrupt is really just a fancy function call to the operating system, through the CPU.

Interrupts in real mode

Interrupts are not a new thing. They were around in the very first Intel processors (the 8088, for example). Although there was no protected mode, you would still use an interrupt to request a service from the operating system, and hardware would use interrupts to notify the CPU (and thus the operating system) of interesting events.

As a matter of fact, interrupt-handling code exists even when there is no kernel. The computer’s BIOS comes loaded with hundreds of interrupt routines, which we gratefully used in our boot loader code in order to access the floppy disk drive and write to the screen. When you think about it, the BIOS actually contains reams of tricky code that talks to peripherals. In the days of MS-DOS, the operating system would make use of the BIOS to talk to lots of hardware and you would only need device drivers for exotic hardware (remember adding those .SYS files to your CONFIG.SYS?). You’ll find a comprehensive list of BIOS interrupts in Ralph Brown’s interrupt list.

Intermezzo: a piece of bad news

This sounds like a good thing. Lots of tricky-to-write code to access hardware has already been implemented in the BIOS, so our future kernel could just talk to the BIOS, right? Now is perhaps a good time to break the bad news. In our kernel, we won’t be able to talk to the BIOS and we can’t use any of its services. This is because BIOS code is 16-bit code that cannot be executed in protected mode. The BIOS is inaccessible to us. We will need to write all required code to talk to hardware again, from scratch, in 32-bits. This will be difficult!

Calling an interrupt in real mode

We’ve used a number of interrupts in our first-stage bootloader code, and we can have a quick look at some of it to see how interrupts work. Here is the function that we used to write a string to the screen. It assumes that a string lives at address ds:si and prints the individual characters in that string until it encounters a NULL-byte:

.func WriteString
WriteString:
  lodsb                   # load byte at ds:si into al (advancing si)
  or     al, al           # test if character is 0 (end)
  jz     WriteString_done # jump to end if 0.

  mov    ah, 0xe          # Subfunction 0xe of int 10h (video teletype output).
  mov    bx, 9            # Set bh (page number) to 0, and bl (attribute) to white (9).
  int    0x10             # call BIOS interrupt.

  jmp    WriteString      # Repeat for next character.

WriteString_done:
  retw
.endfunc

In the middle of this function, an interrupt 0x10 is called. In the BIOS, this interrupt actually has a lot of functions, as you can see here. We must provide a value in register ah that indicates the function to execute. Our function (0xe) writes one character to the screen (“video teletype output”) and takes two additional arguments: the character to write in register al and the attribute (text color) to use in register bx. (Here is the specification).

The CPU is actually not aware that interrupt 0x10 has hundreds of subfunctions. When interrupt 0x10 is called, it simply transfers control the interrupt 0x10 handler in the BIOS, which presumably contains a big switch statement to execute the right function based on the value of register ah.

An interrupt call might have some return values. The can be placed in any register and depend on the specification of the function. For example, looking at the specification for interrupt 0x10, function 0x8 (here is the specification) reads a character and attribute from the screen at the current cursor position. The resulting character is placed in register al, and the color in register ah. For a different interrupt, the registers and the meaning of their values will be different. It’s a lot like a function call.

Defining interrupts

Often, a (real-mode) operating system will want to redefine the way it responds to interrupts, rather than using the existing BIOS code. The CPU actually maintains a list of memory addresses where the interrupt routines live. Initially, these so-called interrupt vectors will all point to the memory occupied by the BIOS. The operating system (or even a completely different piece of software – it’s real mode, so anything is allowed, and crashes are commonplace) can change one or ore of these vectors to cause the CPU to execute code at a different location when an interrupt is called.

We will soon see that the situation in protected mode is similar, but safer.

Interrupts in protected mode

Now that we know what an interrupt is and how it works (at least in real mode), we can look at protected mode. Protected mode also has interrupts and from the perspective of a user program, they work the same way. You place some values in registers defined by the interrupt specification and call the interrupt.

On the CPU’s side, things are mostly similar too. In protected mode, the interrupt vector table still exists. However, since this is protected mode, the entries in that table no longer contain segmented real-mode addresses, but selectors, just like in the global descriptor table. In fact, this table is now called the Interrupt Descriptor Table (IDT). Another difference is that there is no default table. In protected mode, the BIOS is meaningless, so there are no interrupt handlers to execute. It is up to our kernel to set them all up. We will therefore have to provide our own interrupt descriptor table.

Moreover, for the CPU an interrupt call is now a slightly more complex affair. In lieu of simply transferring control to the interrupt handler code when the interrupt is called, the CPU changes the privilege level to match the level specified in the interrupt descriptor for the interrupt. After all, user processes run with a low privilege level, and use interrupts to talk to the kernel, which runs at a high privilege level. When the interrupt is done, the CPU lowers the privilege level again and returns control to the user process code.

Things to do for protected mode

Let’s take a short break and think about what we’ve seen so far and what we’ll need to implement to get to protected mode and run our kernel:

  • Define a global descriptor table (GDT)
  • Let the CPU know where the GDT lives
  • Define an interrupt descriptor table (IDT)
  • Let the CPU know where the IDT lives
  • Provide interrupt handler code for CPU interrupts

That last point is new and it’s the last thing we need to discuss about interrupts. You see, user code and hardware aren’t the only callers of interrupts. The CPU itself can also call an interrupt routine! CPU Interrupts

Errors happen. In real mode, when a program overwrote a piece of memory it shouldn’t have, nothing happened except that your computer would most likely crash. In protected mode, the CPU acts as a guardian of memory and intervenes when something like this happens.

In fact, whenever a user program produces an error, the CPU notices this and calls an interrupt. It is then up to the kernel to do something about it. Usually, the kernel will terminate the offending user process. (If the problem is in the kernel itself, then all bets are off.)

Accessing out-of-bounds memory isn’t the only thing that can go wrong. Another culprit is a simple one: divide by zero. Or, calling an interrupt that has no handler. Here is a complete list of CPU interrupts:

  • Divide-by-zero exception
  • Debug exception
  • Non Maskable Interrupt Exception
  • Breakpoint Exception
  • Into Detected Overflow Exception
  • Out of Bounds Exception
  • Invalid Opcode Exception
  • No Coprocessor Exception
  • Double Fault Exception
  • Coprocessor Segment Overrun Exception
  • Bad TSS Exception
  • Segment Not Present Exception
  • Stack Fault Exception
  • General Protection Fault Exception
  • Page Fault Exception
  • Unknown Interrupt Exception
  • Coprocessor Fault Exception
  • Alignment Check Exception (486+)
  • Machine Check Exception (Pentium/586+)

Most of these are gibberish at this point, but mostly they have to do with protected mode violations. In most cases, they result in process termination. In some cases, the kernel might be able to solve the problem. For example, a user process tries to access some memory that the kernel has recently swapped out to disk. The CPU notices that the memory is marked as out-of-bounds, and yelps. The kernel loads the data from disk into memory and tells the CPU that things are all right. The CPU then lets the user program continue running.

Interrupt Descriptor Table (IDT) structure

The interrupt descriptor table is a list of interrupt descriptors that lives somewhere in memory. We must define all the descriptors we need, then tell the CPU where the table is. It’s actually all very similar to the GDT.

The structure of an interrupt descriptor is:

OffsetSizeNameDescription
02 bytesOffset (low)Low 2 bytes of interrupt handler offset
22 bytesSelectorA code segment selector in the GDT
41 byteZeroZero - unused
51 byteType/attributesType and attributes of the descriptor
62 bytesOffset (high)High 2 bytes of interrupt handler offset

We can see that a descriptor includes a selector from the GDT, so the IDT depends on the GDT. In the GDT, we must define a code segment (and we already have – the kernel has one huge code segment of 4GB) where the interrupt handlers will be stored. The interrupt descriptor then includes an offset to where the handler code begins.

The descriptor also defines an type/attribute byte with a number of flags:

BitsCodeDescription
0..3Gate typeIDT gate type (see below)
4Storage segmentMust be set to 0 for interrupt gates.
5..6Descriptor privilege levelGate call protection. This defines the minimum privilege level the calling code must have. This way, user code may not be able to call some interrupts.
7PresentFor unused interrupts, this is set to 0. Normally, it’s 1.

The so-called task gates are three (plus some for 80286 processor that we can ignore):

ValueDescription
0x580386 32-bit Task gate
0xe80386 32-bit Interrupt gate
0xf80386 32-bit Trap gate

We will be mainly concerned with interrupt gates and trap gates. The difference is that when an interrupt gate is called, the CPU automatically disables interrupts and enables then upon returning from the interrupt handler, which it doesn’t do for trap gates.

Implementing the IDT

Now for a bit of good news. We don’t actually need to implement any interrupt handlers in our second-stage boot loader. We will need to reserve enough memory to hold 256 interrupts (which is 2,048 bytes) and fill it all with zeroes. Then we tell the CPU where this table is, because only then will it allow us to switch to protected mode. The real IDT will defined by our kernel – and from C code, so much nicer – once it’s running.

In order to let the CPU know where the IDT is, we use an IDT pointer. This is the exact same structure we used for the GDT pointer:

FieldSizeDescription
Size2 bytesNumber of bytes (not entries) in the interrupt descriptor table, NOT minus one
Offset4 bytesLinear address of interrupt descriptor table

So, we reserve 2,048 bytes for 256 entries, an set the linear address to 0x0. Why? Because our global descriptor table was placed at 0000:0x0800, which is precisely 2,048 bytes from the start of the physical memory. This way, everything lines up nicely. We then use the CPU’s lidt instruction to let it know where the table is:

idt:
.word  2048  # Size of IDT (256 entries of 8 bytes)
.int   0x0   # Linear address of IDT

load_idt:
  lidt idt

There, we’re all set. At this stage, the CPU knows where the global descriptor table is, and where the interrupt descriptor table is – even though it’s filled with 256 interrupts that have no handlers. That’s all right – for the time being we’ll keep interrupts turned off (cli), which we had already done at the very start of our first-stage boot loader. This way the CPU will not inadvertently call non-existent interrupts should someone press a key on the keyboard.

Summary

What have we done so far?

  • In the previous section, we learned about the global descriptor table (GDT) and how to set it up. It currently holds three entries: a NULL-descriptor (required), a code segment selector of 4GB, and a data segment descriptor of 4GB. This will be enough for our kernel.
  • In this section, we learned about interrupts and how they work. We set up an interrupt descriptor table of 256 empty interrupts – which our kernel will fill later – which is required for switching to protected mode.

We’re still not quite ready to jump to protected mode, though. A few small issues remain, and we’ll have a look at those in the next section on enabling the A20 line.

Continue on to the next part of this guide!