Multiboot keyboard driver triple faults with GRUB (works with QEMU) - why?

2

I've been reading a ton of tutorials about OS development on x86, and so far all has gone well - until now. I can't figure out what the solution is for the life of me.

My goal is to write the simplest possible keyboard driver for x86. Things work well with QEMU, but not with GRUB.

I did my best to emulate mkeykernel based on the article by Arjun Sreedharan. Unfortunately, this problem also exists for mkeykernel.

When running my compiled kernel using qemu-system-i386 -kernel kernel.bin, everything works as expected: I type, and letters are displayed on the screen.

However, when I create and run a GRUB ISO using grub-mkrescue, the system reboots whenever I hit a key.

When running with qemu-system-i386 -cdrom build/myos.iso -d int --no-reboot, I was able to find out that the CPU exception is 0xd General Protection Fault. At first, I thought this was because the GDT was set in an unexpected way by GRUB. But as you'll see below, I added my own GDT and it did not fix the problem.

I also found a close match on StackOverflow here. I followed pretty much all of the advice in that article, especially the one about packing the structs, to no avail.

This is the first time I've ever been so stumped to the point that I wrote a StackOverflow question :) Hopefully someone will be able to see the issue here!

I have included the source code for all relevant files and instructions to build them / recreate the problem below.


First file: kernel.asm

bits 32
section .multiboot
    dd 0x1BADB002   ; Magic number
    dd 0x0          ; Flags
    dd - (0x1BADB002 + 0x0) ; Checksum

section .text

%include "gdt.asm"

; Make global anything that is used in main.c
global start
global print_char_with_asm
global load_gdt
global load_idt
global keyboard_handler
global ioport_in
global ioport_out
global enable_interrupts

extern main         ; Defined in kernel.c
extern handle_keyboard_interrupt

load_gdt:
    lgdt [gdt_descriptor] ; from gdt.asm
    ret

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

enable_interrupts:
    sti
    ret

keyboard_handler:
    pushad
    cld
    call handle_keyboard_interrupt
    popad
    iretd

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

ioport_out:
    mov edx, [esp + 4]
    mov eax, [esp + 8]
    out dx, al
    ret

print_char_with_asm:
    ; OFFSET = (ROW * 80) + COL
    mov eax, [esp + 8]      ; eax = row
    mov edx, 80                     ; 80 (number of cols per row)
    mul edx                             ; now eax = row * 80
    add eax, [esp + 12]     ; now eax = row * 80 + col
    mov edx, 2                      ; * 2 because 2 bytes per char on screen
    mul edx
    mov edx, 0xb8000            ; vid mem start in edx
    add edx, eax                    ; Add our calculated offset
    mov eax, [esp + 4]      ; char c
    mov [edx], al
    ret

start:
    cli             ; Disable interrupts
    mov esp, stack_space
    call main
    hlt

section .bss
resb 8192           ; 8KB for stack
stack_space:

Second file: kernel.c

// ----- Pre-processor constants -----
#define ROWS 25
#define COLS 80
// IDT_SIZE: Specific to x86 architecture
#define IDT_SIZE 256
// KERNEL_CODE_SEGMENT_OFFSET: the first segment after the null segment in gdt.asm
#define KERNEL_CODE_SEGMENT_OFFSET 0x8
// 32-bit Interrupt gate: 0x8E
// ( P=1, DPL=00b, S=0, type=1110b => type_attr=1000_1110b=0x8E) (thanks osdev.org)
#define IDT_INTERRUPT_GATE_32BIT 0x8e
// IO Ports for PICs
#define PIC1_COMMAND_PORT 0x20
#define PIC1_DATA_PORT 0x21
#define PIC2_COMMAND_PORT 0xA0
#define PIC2_DATA_PORT 0xA1
// IO Ports for Keyboard
#define KEYBOARD_DATA_PORT 0x60
#define KEYBOARD_STATUS_PORT 0x64

// ----- Includes -----
#include "keyboard_map.h"

// ----- External functions -----
extern void print_char_with_asm(char c, int row, int col);
extern void load_gdt();
extern void keyboard_handler();
extern char ioport_in(unsigned short port);
extern void ioport_out(unsigned short port, unsigned char data);
extern void load_idt(unsigned int* idt_address);
extern void enable_interrupts();

// ----- Structs -----
struct IDT_pointer {
    unsigned short limit;
    unsigned int base;
} __attribute__((packed));
struct IDT_entry {
    unsigned short offset_lowerbits; // 16 bits
    unsigned short selector; // 16 bits
    unsigned char zero; // 8 bits
    unsigned char type_attr; // 8 bits
    unsigned short offset_upperbits; // 16 bits
} __attribute__((packed));

// ----- Global variables -----
struct IDT_entry IDT[IDT_SIZE]; // This is our entire IDT. Room for 256 interrupts
int cursor_pos = 0;

void init_idt() {
    // Get the address of the keyboard_handler code in kernel.asm as a number
    unsigned int offset = (unsigned int)keyboard_handler;
    // Populate the first entry of the IDT
    // TODO why 0x21 and not 0x0?
    // My guess: 0x0 to 0x2 are reserved for CPU, so we use the first avail
    IDT[0x21].offset_lowerbits = offset & 0x0000FFFF; // lower 16 bits
    IDT[0x21].selector = KERNEL_CODE_SEGMENT_OFFSET;
    IDT[0x21].zero = 0;
    IDT[0x21].type_attr = IDT_INTERRUPT_GATE_32BIT;
    IDT[0x21].offset_upperbits = (offset & 0xFFFF0000) >> 16;
    // Program the PICs - Programmable Interrupt Controllers
    ioport_out(PIC1_COMMAND_PORT, 0x11);
    ioport_out(PIC2_COMMAND_PORT, 0x11);
    // ICW2: Vector Offset (this is what we are fixing)
    ioport_out(PIC1_DATA_PORT, 0x20);
    ioport_out(PIC2_DATA_PORT, 0x28);
    // ICW3: Cascading (how master/slave PICs are wired/daisy chained)
    ioport_out(PIC1_DATA_PORT, 0x0);
    ioport_out(PIC2_DATA_PORT, 0x0);
    // ICW4: "Gives additional information about the environemnt"
    ioport_out(PIC1_DATA_PORT, 0x1);
    ioport_out(PIC2_DATA_PORT, 0x1);
    // Voila! PICs are initialized

    // Mask all interrupts
    ioport_out(PIC1_DATA_PORT, 0xff);
    ioport_out(PIC2_DATA_PORT, 0xff);

    struct IDT_pointer idt_ptr;
    idt_ptr.limit = (sizeof(struct IDT_entry) * IDT_SIZE) - 1;
    idt_ptr.base = (unsigned int) &IDT;
    // Now load this IDT
    load_idt(&idt_ptr);
}

void kb_init() {
    // 0xFD = 1111 1101 in binary. enables only IRQ1
    ioport_out(PIC1_DATA_PORT, 0xFD);
}

void handle_keyboard_interrupt() {
    // Write end of interrupt (EOI)
    ioport_out(PIC1_COMMAND_PORT, 0x20);

    unsigned char status = ioport_in(KEYBOARD_STATUS_PORT);
    // Lowest bit of status will be set if buffer not empty
    // (thanks mkeykernel)
    if (status & 0x1) {
        char keycode = ioport_in(KEYBOARD_DATA_PORT);
        if (keycode < 0 || keycode >= 128) return;
        print_char_with_asm(keyboard_map[keycode],0,cursor_pos);
        cursor_pos++;
    }
}

void clear_screen() {
    int i, j;
    for (i = 0; i < COLS; i++) {
        for (j = 0; j < ROWS; j++) {
            print_char_with_asm(' ',j,i);
        }
    }
}

// ----- Entry point -----
void main() {
    clear_screen();
    load_gdt();
    init_idt();
    kb_init();
    enable_interrupts();
    while(1);
}

Third file: gdt.asm (heavily based off of this handy guide)

; GDT - Global Descriptor Table
gdt_start:
gdt_null:   ; Entry 1: Null entry must be included first (error check)
    dd 0x0  ; double word = 4 bytes = 32 bits
    dd 0x0
gdt_code:   ; Entry 2: Code segment descriptor
    ; Structure:
    ; Segment Base Address (base) = 0x0
    ; Segment Limit (limit) = 0xfffff
    dw 0xffff   ; Limit bits 0-15
    dw 0x0000   ; Base bits 0-15
    db 0x00     ; Base bits 16-23
    ; Flag Set 1:
        ; Segment Present: 0b1
        ; Descriptor Privilege level: 0x00 (ring 0)
        ; Descriptor Type: 0b1 (code/data)
    ; Flag Set 2: Type Field
        ; Code: 0b1 (this is a code segment)
        ; Conforming: 0b0 (Code w/ lower privilege may not call this)
        ; Readable: 0b1 (Readable or execute only? Readable means we can read code constants)
        ; Accessed: 0b0 (Used for debugging and virtual memory. CPU sets bit when accessing segment)
    db 10011010b    ; Flag set 1 and 2
    ; Flag Set 3
        ; Granularity: 0b1 (Set to 1 multiplies limit by 4K. Shift 0xfffff 3 bytes left, allowing to span full 32G of memory)
        ; 32-bit default: 0b1
        ; 64-bit segment: 0b0
        ; AVL: 0b0
    db 11001111b    ; Flag set 3 and limit bits 16-19
    db 0x00     ; Base bits 24-31
gdt_data:
    ; Same except for code flag:
        ; Code: 0b0
    dw 0xfffff  ; Limit bits 0-15
    dw 0x0000   ; Base bits 0-15
    db 0x00     ; Base bits 16-23
    db 10010010b    ; Flag set 1 and 2
    db 11001111b    ; 2nd flags and limit bits 16-19
    db 0x00     ; Base bits 24-31

gdt_end:        ; Needed to calculate GDT size for inclusion in GDT descriptor

; GDT Descriptor
gdt_descriptor:
    dw gdt_end - gdt_start - 1  ; Size of GDT, always less one
    dd gdt_start

; Define constants
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

; In protected mode, set DS = INDEX to select GDT entries
; Then CPU knows to use segment at that offset
; Example: (0x0: NULL segment; 0x8: CODE segment; 0x10: DATA segment)

Fourth file: grub.cfg

menuentry "myos" {
    multiboot /boot/grub/kernel.bin
}

Fifth file: linker.ld

OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
{
    . = 1M;
    .text BLOCK(4K) : ALIGN(4K)
    {
        *(.multiboot)
        *(.text)
    }
    .data : { *(.data) }
    .bss : { *(.bss) }
}

Oops, missed a file - here's keyboard_map.h:

unsigned char keyboard_map[128] = {
  // -------- 0 to 9 --------
  ' ',
  ' ', // escape key
  '1','2','3','4','5','6','7','8',
  // -------- 10 to 19 --------
  '9','0','-','=',
  ' ', // Backspace
  ' ', // Tab
  'q','w','e','r',
  // -------- 20 to 29 --------
  't','y','u','i','o','p','[',']',
  ' ', // Enter
  ' ', // left Ctrl
  // -------- 30 to 39 --------
  'a','s','d','f','g','h','j','k','l',';',
  // -------- 40 to 49 --------
  ' ','`',
  ' ', // left Shift
  ' ','z','x','c','v','b','n',
  // -------- 50 to 59 --------
  'm',',','.',
  '/', // slash, or numpad slash if preceded by keycode 224
  ' ', // right Shift
  '*', // numpad asterisk
  ' ', // left Alt
  ' ', // Spacebar
  ' ',
  ' ', // F1
  // -------- 60 to 69 --------
  ' ', // F2
  ' ', // F3
  ' ', // F4
  ' ', // F5
  ' ', // F6
  ' ', // F7
  ' ', // F8
  ' ', // F9
  ' ', // F10
  ' ',
  // -------- 70 to 79 --------
  ' ', // scroll lock
  '7', // numpad 7, HOME key if preceded by keycode 224
  '8', // numpad 8, up arrow if preceded by keycode 224
  '9', // numpad 9, PAGE UP key if preceded by keycode 224
  '-', // numpad hyphen
  '4', // numpad 4, left arrow if preceded by keycode 224
  '5', // numpad 5
  '6', // numpad 6, right arrow if preceded by keycode 224
  ' ',
  '1', // numpad 1, END key if preceded by keycode 224
  // -------- 80 to 89 --------
  '2', // numpad 2, down arrow if preceded by keycode 224
  '3', // numpad 3, PAGE DOWN key if preceded by keycode 224
  '0', // numpad 0, INSERT key if preceded by keycode 224
  '.', // numpad dot, DELETE key if preceded by keycode 224
  ' ',' ',' ',' ',' ',' ',
  // -------- 90 to 99 --------
  ' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',
  // -------- 100 to 109 --------
  ' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',
  // -------- 110 to 119 --------
  ' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',
  // -------- 120-127 --------
  ' ',' ',' ',' ',' ',' ',' ',' ',
};
// Right control, right alt seem to send
// keycode 224, then the left control/alt keycode
// Arrow keys also send two interrupts, one 224 and then their actual code
// Same for numpad enter
// 197: Num Lock
// 157: Pause|Break (followed by 197?)
// Clicking on screen appears to send keycodes 70, 198
  // Is this the MARK command or something like that?

Paste all the above files into a directory together on Linux. Then...

To compile the kernel:

mkdir build
nasm -f elf32 kernel.asm -o build/boot.o
gcc -m32 -ffreestanding -c kernel.c -o build/kernel.o
ld -m elf_i386 -T linker.ld -o build/kernel.bin build/boot.o build/kernel.o

To run the kernel with QEMU (SHOULD WORK FINE):

qemu-system-i386 -kernel build/kernel-bin

To run the kernel with GRUB (does not work):

mkdir -p build/iso/boot/grub
cp grub.cfg build/iso/boot/grub
cp build/kernel.bin build/iso/boot/grub
grub-mkrescue -o build/myos.iso build/iso
qemu-system-i386 -cdrom build/myos.iso

Has anyone run into this issue before? Is there another resource you'd recommend to get the keyboard as a beginner on x86? I really want to finally get some protected-mode user input with my little mini-OS!

Is there another bootloader than GRUB that I should be using?

TLDR: Simple keyboard driver works with QEMU -kernel option, but fails when an ISO is created using grub-mkrescue.

gcc
x86
kernel
interrupt
osdev
asked on Stack Overflow Jul 13, 2020 by steve • edited Jul 13, 2020 by steve

1 Answer

1

The solution (thanks to @MichaelPetch) was to setup segment registers after loading the GDT. My new entry point:

start:
    lgdt [gdt_descriptor]
    jmp CODE_SEG:.setcs       ; Set CS to our 32-bit flat code selector
    .setcs:
    mov ax, DATA_SEG          ; Setup the segment registers with our flat data selector
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    mov esp, stack_space        ; set stack pointer
    cli             ; Disable interrupts
    mov esp, stack_space
    call main
    hlt

Setting up the GDT and setting the segment registers is required because the Multiboot specification doesn't guarantee that the GDT Record is valid, nor does it guarantee what selector number is for the Code Segment and which one is for the Data Segment. Because of this you need to load your GDT and use the selector values specific to your GDT. Failure to set the Code Segment (CS) selector properly can cause problems when the first interrupt occurs.

I also commented out load_gdt() in the main method so that I'm not doing it twice.

Thank you again, Michael. If you post as an answer, I'll be sure to accept yours :)

answered on Stack Overflow Jul 13, 2020 by steve • edited Jul 14, 2020 by Michael Petch

User contributions licensed under CC BY-SA 3.0