Bootloading

A few words

In this chapter, we will write a fully functioning bootloader that loads a kernel that prints a "hello world" message. I want to start you off with this so you can get a feel for bare metal programming before you invest any more time into learning theory in part 3.

A downside to starting with programming is that you will have to take my word for a lot of the instructions we write without understanding every single one. You will understand everything written here after "Part III: Learning about x86." I will give explanations in this chapter, but they will be missing context and will be large oversimplifications. Please don't get discouraged if you read something and don't understand it in this chapter.

As a general rule in this book: each chapter will start off with a collection of context that you can use to develop your own implementation before you look at my code (with the sole exception of this one). I highly recommend you try things out before using my solutions. You will learn much more if you do so. My code has also not cleaned thoroughly, so you should treat it as a rough guide and not as gospel.

How does the bootloader start?

Before any code gets written, you need to understand how the first instructions we write actually get executed. When we run QEMU with our disk image, QEMU emulates a computer starting up. The virtual machine begins by running its firmware, which in this case provides the BIOS.

The BIOS performs some basic hardware initialization and then looks for a bootable device. Our print.img/kernel.img is being used as that device. The BIOS reads the first sector of the image into memory at address 0x7C00 and checks that the final two bytes contain the boot signature 0xAA55.

If the signature is present, the BIOS treats the sector as a boot sector and transfers control to the code that was loaded at 0x7C00. This means that the start label in our assembly code is where our bootloader begins executing.

We don't really need to know how exactly the BIOS performs the operations spoken about. We only need to know that QEMU provides the virtual machine, the BIOS starts the machine and loads our boot sector, and then the CPU begins executing our code.

Printing in BIOS

When printing a string in BIOS, you need to print the string one character at a time in a loop. This is a common practice in assembly where you put the address of the string in the SI register, read a character from [SI], check whether you've reached the end, send the character to whatever output mechanism is used, increment SI, and then repeat. The output mechanism is provided by the BIOS in the form of an interrupt.

Here's some basic code I made for printing, which we will be expanding upon later to do things like reading from storage and loading into memory:

start:
    mov ax, 07C0h
    mov ds, ax

    mov si, title_string
    call print_string
    jmp $

print_string:
    mov ah, 0Eh ; bios number 0Eh, sets for teletype output function
print_char:
    lodsb ; loads byte at SI, into AL and increments SI

    cmp al, 0 ; 0 stored in al if at end of string
    je printing_finished

    int 10h ;bios interrupt 0x10, to print char stored in AL
    jmp print_char
printing_finished:
    ret

title_string db 'Welcome to the lytlnybl bootloader!',0

times 510-($-$$) db 0 ; pads the rest of the bootloader with 510 bytes, aiming for a 512 byte bootloader
dw 0xAA55 ; specifies the end of the bootloader, recognised by bios

The start section:

These first two lines are an example of something you will have to take my word on, as they relate to a larger topic called memory segmentation.

mov ax, 07C0h
mov ds, ax

With these two lines, the first line loads the value 0x07C0 into the ax register; this is the segment value we use to access the bootloader at physical address 0x7C00. You will understand this fully when we cover x86 segmentation, but all you need to know for now is that it makes it so all data accessed is accessed around the 0x7C00 (not 0x07C0) address.

Just after that, we set ds to the value in ax. This will set the ds register, which represents the data segment. If we didn't include this line, ds could refer to a different segment, causing mov si, title_string and lodsb to access the wrong memory.

Continuing with the start section, we then have our next couple of lines, these being:

mov si, title_string
call print_string
jmp $

mov si, title_string is what tells us what string we need to print. We also use db (define byte) to store our string in memory, as seen here: title_string db 'Welcome to the lytlnybl bootloader!', 0. By setting SI to title_string the value in SI is the memory location of the first character in the string. Like in C, strings are treated as arrays, which are terminated by a zero byte to mark the end of the string. We would then increment SI and print one character at a time when we go into our printing sections.

call print_string calls the print_string label and returns after printing. jmp $ jumps to itself, creating an infinite loop and marking the end of the program.

The print_string section:

Let's just look at the whole of our print section:

print_string:
    mov ah, 0Eh ; bios number 0Eh, sets for teletype output function
print_char:
    lodsb ; loads byte at SI, into AL and increments SI
            
    cmp al, 0 ; 0 stored in al if at end of string
    je printing_finished
            
    int 10h ;bios interrupt 0x10, to print char stored in AL
    jmp print_char
printing_finished:
    ret

mov ah, 0Eh moves the value 0Eh into the ah register. In BIOS interrupt services, ah typically specifies the function requested. In this case, 0Eh is the function number for teletype output.

Then we enter the print_char loop, which repeats until the end of the string.

The lodsb instruction (as said in the comment) loads the byte at the memory address pointed to by the ds:si (which means 0x7C00 + SI offset/address) into the al register. Then it increments the si register to point to the next byte in memory.

Next we have cmp al, 0 and je printing_finished. The first instruction compares the value in the al register with 0, checking if it's the null terminator. After that, we use je (which means jump if equal) to jump to printing_finished if the value in al is the null terminator.

Then, if we are not at the end of our string, we carry out our final instructions: int 10h and jmp print_char. The first of the two invokes the BIOS interrupt for video services: 10h. The value in AH is 0Eh indicating a teletype output which makes the byte in AL be interpreted as an ASCII character which is then printed to the screen.

Then, in printing_finished, we return to the caller

Final Two lines

I'll now explain the last two lines, which may look a little confusing. These are:

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

The first line tells the assembler to add enough zeroes to make the bootloader 510 bytes long. This ensures that the bootloader fills up most of the available space in the 512-byte sector reserved for the bootloader. The next line adds the boot signature, 0xAA55, to the final two bytes of the sector. This tells the BIOS uses to recognize the sector is bootable. When the BIOS loads the bootloader, it checks for this signature to make sure it's a legitimate bootable sector before proceeding with the boot process.

That wraps up our printing, We use printing code a couple of times in the BIOS. Although when we move into C, printing will be a lot easier. Now, it's time to move onto the next section and load our kernel into memory.

Before we move on, here is the Makefile for our code:

# Assembler
NASM := nasm

# Assembler flags
NASMFLAGS := -f bin

# Source files
SRC := print.asm

build: $(SRC)
    $(NASM) $(NASMFLAGS) -o print.o $(SRC)
    dd if=print.o of=print.img
    qemu-system-x86_64 print.img
    rm -f print.o

clean:
    rm -f *.o *.img

Loading the kernel

To load the kernel, we actually need a kernel to load. Here's a simple one:

start:
    mov ax, cs
    mov ds, ax

    mov si, hello_string
    call print_string

    jmp $

print_string:
    mov ah, 0Eh

print_char:
    lodsb

    cmp al, 0
    je done
    
    int 10h

    jmp print_char

done:
    ret

    hello_string db 'Hello World!, i am lytlnyblOS', 0

The kernel code is close to the printing code we wrote earlier, with the main difference being how we set up the data segment; instead of setting ds to a fixed segment value, we copy the current code segment from cs into ds. This makes ds and cs refer to the same segment.

Now let's change the bootloader to accommodate our new kernel:

start:
    mov ax, 07C0h
    mov ds, ax

    mov si, title_string
    call print_string

    mov si, message_string
    call print_string

    call load_kernel_from_disk
    jmp 0900h:0000 ; gives control to the kernel by jumping to its starting point.

load_kernel_from_disk:
    mov ax, 0900h
    mov es, ax
    
    mov ah, 02h ; service number, BIOS read-sector function
    mov al, 01h ; number of sectors we want to read from (only simple kernel for now, so less than 512 bytes)
    
    mov ch, 0h ; track number we would like to read from, is just 0.
    mov cl, 02h ; sector number that we would like to read its content, this is the second sector

    mov dh, 0h ; head number 0 
    mov dl, 80h ; BIOS drive number: 80h is the first hard disk

    mov bx, 0h ; memory adress that content will be loaded into
    int 13h ; 13h provides services related to hard disk

    ; INT 13h clears carry flag on success and sets it on error.
    jc kernel_load_error

    ret

kernel_load_error:
    mov si, load_error_string
    call print_string

    jmp $

print_string:
    mov ah, 0Eh ; bios number 0Eh, sets for teletype output function
print_char:
    lodsb ; loads byte at SI, into AL and increments SI

    cmp al, 0 ; 0 stored in al if at end of string
    je printing_finished

    int 10h ;bios interrupt 0x10, to print char stored in AL

    jmp print_char
printing_finished:
    ;print new line
    mov al, 10d ; ASCII code for new line
    int 10h 

    ;read current cursor position
    mov ah, 03h ; function to read cursor position
    mov bh, 0 ; page number 0 for default page
    int 10h ; 10h now used to read cursor position

    ;move cursor to beggining
    mov ah, 02h ; function to set cursor position
    mov dl, 0 ; column number (0 for begginign of line)
    int 10h ; 0x10 to set cursor pos

    ret

title_string db 'Welcome to the lytlnybl bootloader!',0
message_string db 'Loading up the kernel for you...',0
load_error_string db 'Oh oh!, there was a problem loading the kernel',0

times 510-($-$$) db 0 ; pads the rest of the bootloader with 510 bytes, aiming for a 512 byte bootloader
dw 0xAA55 ; specifies the end of the bootloader, recognised by bios

There's not that much that is new. Remember that if you don't understand much fret not as it will get explained in the next part of this book.

The load_kernel_from_disk section:

After we print two times (for loading and intro messages), we go straight into our label for loading the kernel from disk. Its goal is to read the kernel from the disk and load it into memory.

We first set the segment address to 0900h by loading it into ax and then copying it to es. The BIOS will load the kernel at ES:BX

Next, we set the disk read parameters with mov ah, 02h and mov al, 01h. ah = 02h selects the BIOS read-sectors function, while al = 01h tells the BIOS to read one sector.

The next lines, mov ch, 00h and mov cl, 02h set the cylinder and sector we want to read. ch contains the low 8 bits of the cylinder number, so setting it to 0 selects cylinder 0. cl contains the sector number in its lower 6 bits, so setting it to 2 selects the second sector.

We then specify the disk and head with mov dh, 0 and mov dl, 80h. dh selects head 0, while dl contains the BIOS drive number. 80h selects the first hard disk, while 81h selects the second.

Then mov bx, 0h sets the offset within the es segment where we will load the kernel, which will just be 0 as we want to load it into the start of our segment.

Our final line is int 13h, which invokes the BIOS disk services using the parameters we set int he registers

Then the only thing left to do is check for errors; the interrupt earlier would set the carry flag if there was an error. We can just use jc (jump if carry) to jump to an error handling subroutine, which will just output a message signifying an error with an infinite loop.

That's all on reading from the disk; let's now look at the changes that we made to printing, which allows us to print multiple lines.

Printing Changes:

The only real changes to printing made in our code are the changes to the printing_finished section of our code, as seen here:

printing_finished:
    ;print new line
    mov al, 10d ; ASCII code for new line
    int 10h 

    ;read current cursor position
    mov ah, 03h ; function to read cursor position
    mov bh, 0 ; page number 0 for default page
    int 10h ; 10h now used to read cursor position

    ;move cursor to beggining
    mov ah, 02h ; function to set cursor position
    mov dl, 0 ; column number (0 for begginign of line)
    int 10h ; 0x10 to set cursor pos

    ret

We first output the ASCII line feed (10) which advances the cursor to the next row. Next, we read the cursor position and reset the column to 0. After that, we read the current cursor position. This is not strictly necessary, but it gives us the current row in dh and column in dl. We then reset the column to 0 while keeping the current row. The final block moves the cursor to column 0 on the current row, which is also explained in the comments for the code.

There we have it. After writing all this, you can say you've made your own bootloader and kernel (albeit simple ones). This may seem pretty dull, but just consider the fact that this was all done on bare metal hardware without an OS to support us.

Here is the Makefile for the kernel and bootloader:

BOOT_FILE = bootloader/bootloader.asm 
KERNEL_FILE = kernel/basic_kernel.asm 
        
build: $(BOOT_FILE) $(KERNEL_FILE)
    nasm -f bin $(BOOT_FILE) -o bootstrap.o
    nasm -f bin $(KERNEL_FILE) -o kernel.o
    dd if=bootstrap.o of=kernel.img
    dd seek=1 conv=sync if=kernel.o of=kernel.img bs=512
    qemu-system-x86_64 kernel.img
            
clean:
    rm -f *.o