Asm Bootloader with C Kernel

Recall Some Past

On an x86 machine, the BIOS selects a boot device and copies the first sector from the device into physical memory at address 0x7C00. This first sector, known as the boot sector, is 512 bytes in size. It contains the boot loader code, a partition table, the disk signature, and a "magic number" that the BIOS checks to ensure the sector is intended to be a boot sector. After this, the BIOS instructs the CPU to jump to the start of the boot loader code, thereby passing control to the boot loader.

Steps Bootloader to Kernel

In this chapter we will be only concerned about the boot loader code, which will start the hello world kernel. Because we can't fit whole thing into 512 bytes. In order to start our kernel, the our bootloader will have to perform the following tasks:

  1. Load the kernel from the disk into memory.
  2. Set up the Global Descriptor Table (GDT).
  3. Switch from 16-bit real mode to 32-bit protected mode and pass control to the loaded kernel.

1 Load the Kernel from Disk into Memory

The bootloader must read the kernel from the disk and load it into a specific memory location. This typically involves using BIOS interrupts to read from the disk.

2 Set Up the Global Descriptor Table (GDT)

The GDT defines the memory segments for code, data, and other purposes. Here's an example of setting up the GDT:

section .data
    ; Define GDT entries
    gdt_data:
        ; Null segment descriptor
        dd 0
        dd 0
        ; Code segment descriptor
        dw 0xFFFF
        dw 0
        db 0
        db 10011010b     ; 32-bit code segment, present, ring 0
        db 11001111b     ; Limit bits 16-19
        db 0
        ; Data segment descriptor
        dw 0xFFFF
        dw 0
        db 0
        db 10010010b     ; 32-bit data segment, present, ring 0
        db 11001111b     ; Limit bits 16-19
        db 0

    ; GDT descriptor
    gdt_descriptor:
        dw $ - gdt_data - 1   ; Limit (size of GDT), 16 bytes (2 entries * 8 bytes per entry) - 1
        dd gdt_data           ; Base address of GDT

section .text
    global load_gdt

load_gdt:
    ; Load GDT
    lgdt [gdt_descriptor]
    ret

3 Switch from 16-bit Real Mode to 32-bit Protected Mode

Enable the Protection Enable (PE) bit in the Control Register 0 (CR0), set up the segment registers, and perform a far jump to load the new code segment.

section .text
    global _start

_start:
    ; Load the GDT
    call load_gdt

    ; Enable protected mode (set PE bit in CR0)
    mov eax, cr0
    or eax, 0x00000001
    mov cr0, eax

    ; Set up data segment registers
    mov ax, 0x10       ; Data segment selector (GDT index 1)
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax

    ; Jump to the next instruction to ensure segment registers are loaded
    jmp 0x08:protected_mode_entry

protected_mode_entry:
    ; Now in protected mode
    ; Your kernel entry point here
    call load_idt   ; Optionally load IDT if interrupts are needed

    ; Your kernel code here

Load the IDT (Optional)

If you need to handle the interrupts in protected mode, you will need to set up and load the Interrupt Descriptor Table (IDT).

section .data
    ; Define IDT entries
    idt_data:
        times 256 dd 0        ; 256 entries, each entry is 8 bytes long

    ; IDT descriptor
    idt_descriptor:
        dw $ - idt_data - 1   ; Limit (size of IDT), 256 entries * 8 bytes per entry - 1
        dd idt_data           ; Base address of IDT

section .text
    global load_idt

load_idt:
    ; Load IDT
    lidt [idt_descriptor]
    ret

Writing the Boot Loader

MBR (Master Boot Record) File

The main assembly file for the boot loader contains the definition of the master boot record, as well as include statements for all relevant helper modules. Below is the whole code of this file and then discuss each section individually.

[BITS 16]		; Assemble for 16-bit mode
[org 0x7c00]	; The boot sector is loaded at address 0x7c00
KERNEL_OFFSET equ 0x1000 ; The same one we used when linking the kernel

mov [BOOT_DRIVE], dl ; Remember that the BIOS sets us the boot drive in 'dl' on boot

; set up the stack
mov bp, 0x9000
mov sp, bp

mov bx, MSG_16BIT_MODE
call print16
call print16_nl

call load_kernel ; read the kernel from disk
call switch_to_32bit ; disable interrupts, load GDT,  etc. Finally jumps to 'BEGIN_PM'
jmp $ ; Never executed

%include "boot/print-16bit.asm"
%include "boot/print-32bit.asm"
%include "boot/disk.asm"
%include "boot/gdt.asm"
%include "boot/switch-to-32bit.asm"

[bits 16]
load_kernel:
    mov bx, MSG_LOAD_KERNEL
    call print16
    call print16_nl

    mov bx, KERNEL_OFFSET ; Read from disk and store in 0x1000
    mov dh, 31
    mov dl, [BOOT_DRIVE]
    call disk_load
    ret

[bits 32]
BEGIN_32BIT:
    mov ebx, MSG_32BIT_MODE
    call print32
    call KERNEL_OFFSET ; Give control to the kernel
    jmp $ ; Stay here when the kernel returns control to us (if ever)


BOOT_DRIVE db 0 ; It is a good idea to store it in memory because 'dl' may get overwritten
MSG_16BIT_MODE db "Started in 16-bit Real Mode", 0
MSG_32BIT_MODE db "Landed in 32-bit Protected Mode", 0
MSG_LOAD_KERNEL db "Loading kernel into memory", 0

; padding
times 510 - ($-$$) db 0
dw 0xaa55

Explanation:

When developing a bootloader, the first thing to notice is that we are going to switch between 16-bit real mode and 32-bit protected mode. Therefore, we need to instruct the assembler whether it should generate 16-bit or 32-bit instructions. This can be done by using the [bits 16] and [bits 32] directives, respectively. We start with 16-bit instructions since the BIOS jumps to the bootloader while the CPU is still in 16-bit mode.

  • Setting up 16-bit mode: [BITS 16] directive tells the assembler to use 16-bit instructions.
    • [bits 16]:This directive tells the assembler to generate 16-bit code, which is necessary because the CPU starts in real mode (16-bit mode).
    • [bits 32]: After transitioning to protected mode, this directive is used to generate 32-bit code.
  • Origin: [org 0x7c00] directive in assembly language specifies the starting offset for the code or data that follows. This directive informs the assembler about the memory address at which the code will be loaded.
    • The BIOS loads the first sector of the boot device (which is 512 bytes) into memory at the address 0x7C00. Therefore, we use the ORG 0x7C00 directive to ensure that all addresses and offsets in the bootloader code are calculated correctly.
    • This directive tells the assembler that the subsequent code should be assembled as if it starts at memory address 0c7c00. This is important because the BIOS loads the boot sector to this address.
KERNEL_OFFSET equ 0x1000
  • This statement defines an assembler constant called KERNEL_OFFSET with the value 0x1000 which we will use later on when loading the kernel into memory and jumping to its entry point.
mov [BOOT_DRIVE], dl ; Remember that the BIOS sets us the boot drive in 'dl' on boot
  • The line mov [BOOT_DRIVE], dl is used to store the value of the DL register into a memory location labeled BOOT_DRIVE. This is important because the BIOS sets the DL register to the drive number from which the system booted. By storing this value, the bootloader can later reference the boot drive when necessary, for example, to read additional sectors from the same drive.
mov bp, 0x9000
mov sp, bp
  • These instructions are used to set up the stack in assembly language.
    • mov bp, 0x9000: This instruction moves the value 0x9000 into the BP (base pointer) register. The value 0x9000 is typically chosen to point to a location in memory that is safe to use for the stack. This value is arbitrary but chosen to avoid areas of memory that might be used for other purposes by the bootloader or operating system.
      0x9000 is chosen to make sure we are far away enough from our other boot loader related memory to avoid collisions.
    • mov sp, bp: This instruction copies the value in the BP register into the SP (stack pointer) register. Since BP was just set to 0x9000, SP is now also set to 0x9000. The SP register points to the current top of the stack, so setting it to 0x9000 initialize the stack at start at this memory location.
  • Setting up the stack in this way is important because it ensures that the stack has a known, safe starting point. The stack is used for function calls, storing return addresses, local variables, and handling interrupts, so it needs to be correctly initialized.
  • Stack will be used, e.g., by the call and ret statements.
  • Note: Stack grows downwards (high to low memory).

What would happen if we don't set the Stack:

  1. Unpredictable Behavior: The stack pointer (SP) might be pointing to an unknown location in memory, which could cause your program to overwrite critical data or execute invalid instructions, leading to crashes or undefined behavior.
  2. Corrupt Return Addresses: Function calls rely on the stack to store return addresses. If the stack is not set up correctly, the return addresses could be stored in the wrong place, causing the program to return to incorrect locations and leading to unpredictable execution flow.
  3. Local Variables Issues: Local variables in functions are often stored on the stack. Without a properly initialized stack, local variables may corrupt other data or be corrupted themselves, resulting in erratic program behavior.
  4. Interrupt Handling: If interrupts are enabled and the stack is not set up properly, the CPU might push register values and return addresses onto an invalid or unintended stack location during an interrupt, leading to system instability or crashes when trying to handle interrupts.
  5. Parameter Passing: In many calling conventions, parameters to functions are passed on the stack. Without a properly initialized stack, parameters might not be passed correctly, causing functions to operate on incorrect data.

MBR without Stack Setup:

[BITS 16]
ORG 0x7C00

start:
    ; No stack setup here

    ; Trying to call a function
    call print_message

    ; Infinite loop to halt execution
    jmp $

print_message:
    mov si, message
.print_char:
    lodsb
    or al, al
    jz .done
    mov ah, 0x0E
    int 0x10
    jmp .print_char
.done:
    ret

message db 'Hello, World!', 0

times 510-($-$$) db 0
dw 0xAA55

MBR with Stack Setup

[BITS 16]
ORG 0x7C00

start:
    ; Set up the stack
    mov bp, 0x9000
    mov sp, bp

    ; Now call the function
    call print_message

    ; Infinite loop to halt execution
    jmp $

print_message:
    mov si, message
.print_char:
    lodsb
    or al, al
    jz .done
    mov ah, 0x0E
    int 0x10
    jmp .print_char
.done:
    ret

message db 'Hello, World!', 0

times 510-($-$$) db 0
dw 0xAA55

In the first example, without stack setup:

  • The call print_message instruction pushes the return address onto an uninitialized stack. If the SP register is pointing to an invalid location, this could overwrite important data or cause the program to crash when it tries to return from print_message.

In the second example, with stack setup:

  • The stack pointer is explicitly set to 0x9000, a known safe location. The call print_message instruction pushes the return address onto this properly initialized stack. When print_message returns, the CPU correctly retrieves the return address from the stack, ensuring proper execution flow.

mov bx, MSG_16BIT_MODE
call print16
call print16_nl
  • mov bx, MSG_16BIT_MODE: This instruction moves the address of the string MSG_16BIT_MODE into the BX register. The BX register is then used as a pointer to the string data.
  • call print16: This instruction calls the print16 function, which is responsible for printing the string pointed to by the BX register to the screen in 16-bit real mode.
  • call println_nl: This instruction calls the print16_nl function, which prints a newline (carriage return and line feed) to the screen, ensuring the next output starts on a new line.
call load_kernel ; read the kernel from disk
  • This instruction calls the function load_kernel which reads the disk (using int 0x13 bios interrupt) and loads the kernel from the disk into memory at the KERNEL_OFFSET address (0x1000).
  • Loading the Boot Drive: The boot drive number is stored in dl, set by the BIOS, and used to read from the correct disk.
  • Reading the Kernel: The int 0x13 BIOS interrupt is used to read a sector from the disk. The parameters for the read operation (drive number, head, cylinder, sector, etc.) are set up in the registers.
  • Error Handling: If the carry flag is set after the interrupt call, it indicates an error. The code jumps to read_error to print an error message and halt the system.
call switch_to_32bit ; disable interrupts, load GDT,  etc. Finally jumps to 'BEGIN_PM'

After done with loading of the kernel, we call the function switch_to_32bit.

  • It prepares everything needed in order to switch to 32 bit protected mode.
  • Perform switch, do far jump to 32 bit segment code.

Reading from Disk (disk.asm)

BIOS interrupts provide a convenient and standardized interface for accessing disk I/O functionality, making the process much simpler for bootloader developers. Without BIOS interrupts, bootloader development would indeed be significantly more complex, requiring direct interfacing with I/O devices such as hard disks or floppy drives.

; load 'dh' sectors from drive 'dl' into ES:BX
disk_load:
    pusha
    ; reading from disk requires setting specific values in all registers
    ; so we will overwrite our input parameters from 'dx'. Let's save it
    ; to the stack for later use.
    push dx

    mov ah, 0x02 ; ah <- int 0x13 function. 0x02 = 'read'
    mov al, dh   ; al <- number of sectors to read (0x01 .. 0x80)
    mov cl, 0x02 ; cl <- sector (0x01 .. 0x11)
                 ; 0x01 is our boot sector, 0x02 is the first 'available' sector
    mov ch, 0x00 ; ch <- cylinder (0x0 .. 0x3FF, upper 2 bits in 'cl')
    ; dl <- drive number. Our caller sets it as a parameter and gets it from BIOS
    ; (0 = floppy, 1 = floppy2, 0x80 = hdd, 0x81 = hdd2)
    mov dh, 0x00 ; dh <- head number (0x0 .. 0xF)

    ; [es:bx] <- pointer to buffer where the data will be stored
    ; caller sets it up for us, and it is actually the standard location for int 13h
    int 0x13      ; BIOS interrupt
    jc disk_error ; if error (stored in the carry bit)

    pop dx
    cmp al, dh    ; BIOS also sets 'al' to the # of sectors read. Compare it.
    jne sectors_error
    popa
    ret


disk_error:
    mov bx, DISK_ERROR
    call print16
    call print16_nl
    mov dh, ah ; ah = error code, dl = disk drive that dropped the error
    call print16_hex ; check out the code at http://stanislavs.org/helppc/int_13-1.html
    jmp disk_loop

sectors_error:
    mov bx, SECTORS_ERROR
    call print16

disk_loop:
    jmp $

DISK_ERROR: db "Disk read error", 0
SECTORS_ERROR: db "Incorrect number of sectors read", 0
  • This disk_load function reads dh sectors from drive dl into the memory location pointed to by ES:BX.
  • It utilizes BIOS interrupt 0x13 to perform disk I/O operations.
  • It also includes error handling in case of disk read errors or an incorrect number of sectors read.

Recap:

RegisterParameter
ahMode (0x02 = read from the disk)
alNumber of sectors to read
chCylinder
clSector
dhHead
dlDrive
es:bxMemory address to load into (buffer address pointer)

If there are disk errors, BIOS will set the carry bit. In that case we should usually show an error message to the user.

The main parts of this file is the disk_load procedure.

  1. The memory location to place the read data into (bx)
  2. The number of sectors to read (dh)
  3. The disk to read from (dl)
  • First thing every procedure should do is to push all general purpose registers (ax, bx, cx, dx) to the stack using pusha so we can pop them back before returning in order to avoid side effects of the procedure.
  • Additionally we are pushing the number of sectors to read (which is stored in the high part of the dx register) to the stack because we need to set dh to the head number before sending the BIOS interrupt signal and we want to compare the expected number of sectors read to the actual one reported by BIOS to detect errors when we are done.
  • Now we can set all required input parameters in the respective registers and send the interrupt. Keep in mind that bx and dl are already set correctly by the caller. As the goal is to read the next sector on disk, right after the boot sector, we will read from the boot drive starting at sector 2, cylinder 0, head 0.
  • After the int 0x13 has been executed, our kernel should be loaded into memory. To make sure there were no problems, we should check two things: First, whether there was a disk error (indicated by the carry bit) using a conditional jump based on the carry bit jc disk_error. Second, whether the number of sectors read (set as a return value of the interrupt in al) matches the number of sectors we attempted to read (popped from stack into dh) using a comparison instruction cmp al, dh and a conditional jump in case they are not equal jne sectors_error.
  • In case something went wrong we will run into an infinite loop. If everything went fine, we are returning from the procedure back to the main function. The next task is to prepare the GDT so we can switch to 32 bit protected mode.

Global Descriptor Table (GDT)

In 16-bit real mode, memory segmentation works differently compared to protected mode. In protected mode, memory segments are defined by segment descriptors, which are part of the Global Descriptor Table (GDT).

Here's how memory segmentation works in protected mode:

  1. Segment Descriptors: Memory segments are defined by segment descriptors. These descriptors contain information about the base address, limit, access rights, and other attributes of the memory segment.
  2. Global Descriptor Table (GDT): The GDT is a table that contains segment descriptors. Each entry in the GDT describes a memory segment.
  3. Selector: Instead of directly specifying a segment's base address and limit, in protected mode, you use a selector. A selector is an index into the GDT.
  4. Segment Register: To use a memory segment, you load its selector into a segment register (e.g., CS, DS, ES, SS). Once loaded, the segment register points to the segment descriptor in the GDT.

In our boot loader, we will set up the simplest possible Global Descriptor Table (GDT), resembling a flat memory model. This GDT will have the following structure:

  1. Null Segment Descriptor:
    1. This serves as a safety mechanism to catch errors where our code forgets to select a memory segment, thus yielding an invalid segment as the default one.
  2. Code Segment Descriptor (4 GB):
    1. This descriptor defines a code segment spanning the complete 4 GB of addressable memory.
  3. Data Segment Descriptor (4 GB):
    1. This descriptor defines a data segment overlapping with the code segment and spanning the complete 4 GB of addressable memory.
gdt_start: ; don't remove the labels, they're needed to compute sizes and jumps
    ; the GDT starts with a null 8-byte
    dd 0x0 ; 4 byte
    dd 0x0 ; 4 byte

; GDT for code segment. base = 0x00000000, length = 0xfffff
; for flags, refer to os-dev.pdf document, page 36
gdt_code:
    dw 0xffff    ; segment length, bits 0-15
    dw 0x0       ; segment base, bits 0-15
    db 0x0       ; segment base, bits 16-23
    db 10011010b ; flags (8 bits)
    db 11001111b ; flags (4 bits) + segment length, bits 16-19
    db 0x0       ; segment base, bits 24-31

; GDT for data segment. base and length identical to code segment
; some flags changed.
gdt_data:
    dw 0xffff    ; segment length, bits 0-15
    dw 0x0       ; segment base, bits 0-15
    db 0x0       ; segment base, bits 16-23
    db 10010010b ; flags (8 bits)
    db 11001111b ; flags (4 bits) + segment length, bits 16-19
    db 0x0       ; segment base, bits 24-31

gdt_end:

; GDT descriptor
gdt_descriptor:
    dw gdt_end - gdt_start - 1 ; size (16 bit), always one less of its true size
    dd gdt_start ; address (32 bit)

; define some constants for later use
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

Switching to Protected Mode

In order to switch to 32 bit protected mode, so that we can hand over control to our 32 bit kernel, below are the steps to be done:

  1. Disable interrupts using the cli instruction.
  2. Load the GDT descriptor into the GDT register using the lgdt instruction.
  3. Enable protected mode in the control register cr0.
  4. Far jump into our code segment using jmp. This needs to be a far jump so it flushes the CPU pipeline, getting rid of any prefetched 16 bit instructions left in there.
  5. Setup all segment registers (ds, ss, es, fs, gs) to point to our single 4 GB data segment.
  6. Setup a new stack by setting the 32 bit bottom pointer (ebp) and stack pointer (esp).
  7. Jump back to mbr.asm and give control to the kernel by calling our 32 bit kernel entry procedure.
[bits 16]
switch_to_32bit:
    cli ; 1. disable interrupts
    lgdt [gdt_descriptor] ; 2. load the GDT descriptor
    mov eax, cr0
    or eax, 0x1 ; 3. set 32-bit mode bit in cr0
    mov cr0, eax
    jmp CODE_SEG:init_32bit ; 4. far jump by using a different segment

[bits 32]
init_32bit: ; we are now using 32-bit instructions
    mov ax, DATA_SEG ; 5. update the segment registers
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    mov ebp, 0x90000 ; 6. update the stack right at the top of the free space
    mov esp, ebp

    call BEGIN_32BIT ; 7. Call a well-known label with useful code
;; code in mbr.asm

[bits 32]
BEGIN_32BIT:
    mov ebx, MSG_32BIT_MODE
    call print32
    call KERNEL_OFFSET ; Give control to the kernel
    jmp $ ; Stay here when the kernel returns control to us (if ever)

Writing a Dummy C Kernel

Transitioning to protected mode opens up the possibility of writing code in higher-level languages like C. We can start by writing a simple C function that we can call from our bootloader. However, it's important to remember that once we leave 16-bit real mode, we won't have the BIOS at our disposal anymore, and we'll need to write our own I/O drivers.

For the time being, the task of our dummy kernel will be to output the letter J in the top left corner of the screen. To do that we will modify the video memory directly. In color displays with VGA text mode enabled, the memory starts at address 0xb8000.

Each character consists of 2 bytes

  • The first byte represents the ASCII encoded character.
  • The second byte contains color information.

Below is the simple main function inside kernel.c that prints an J in the top left corner of the screen.

void kernel_main() {
    char* video_memory = (char*) 0xb8000;
    *video_memory = 'J';
}

or

void kernel_main() {
    // Pointer to the video memory
    char* video_memory = (char*) 0xb8000;
    // Character to display
    char character = 'J';
    // Attribute byte: white text on black background
    char attribute_byte = 0x0F;
    
    // Calculate the memory offset
    int offset = 0;
    // Set the character at the top left corner of the screen
    video_memory[offset] = character;
    video_memory[offset + 1] = attribute_byte;
}

Kernel Entry

We have not called the kernel_main function yet. We need to call it in the mbr.asm

Wrapping up

# $@ = target file
# $< = first dependency
# $^ = all dependencies

# First rule is the one executed when no parameters are fed to the Makefile
all: run

kernel.bin: kernel-entry.o kernel.o
    ld -m elf_i386 -o $@ -Ttext 0x1000 $^ --oformat binary

kernel-entry.o: kernel-entry.asm
    nasm $< -f elf -o $@

kernel.o: kernel.c
    gcc -m32 -fno-pic -ffreestanding -c $< -o $@

mbr.bin: mbr.asm
    nasm $< -f bin -o $@

os-image.bin: mbr.bin kernel.bin
    cat $^ > $@

run: os-image.bin
    qemu-system-i386 -fda $<

clean:
    $(RM) *.bin *.o *.dis