Up | Home

Bootloader

Table of Contents

1. Introduction

The bootloader is a computer program responsible for booting the operating system. The main responsibilities of the bootloader are:

  1. Obtain information about the system. This information usually needs to be provided to the kernel somehow.
  2. Switch to the specific environment that is expected by the kernel. This normally includes switching to Protected Mode (i.e. 32-bit mode) and enabling the A20 line.
  3. Load the kernel into memory, and jump to it.

This project uses a tiny bootloader written from scratch, that aims to be compliant with the Multiboot 1 protocol. This way, since the kernel is also compliant with this protocol, the kernel and the bootloader should work with any other component that follows the Multiboot 1 standard.

The bootloader is divided into two stages, Stage 1 and Stage 2. This division is mainly needed because of size restrictions of the Stage 1 binary, as explained below.

2. Startup and BIOS

When the machine is turned on, the CPU immediately starts execution at the BIOS program, which usually stored in some Read-Only Memory (ROM). This program performs basic hardware initialization, before transferring control to a bootable device.

To find a valid bootable device, the BIOS looks for bytes 0x55 and 0xAA in offsets 510 and 511 of each possible device. The order in which the BIOS searches for bootable devices (called the boot sequence) is stored in the CMOS. If the BIOS doesn’t find a valid bootable device, it will show an error and halt.

Once the BIOS has found a valid bootable device1, it will load its first 512 bytes into the physical address 0x7C00, and jump there, executing the instructions it just loaded. In our case, the first 510 bytes of the device can be used for the Stage 1 bootloader, which will then load the Stage 2 binary.

3. Stage 1

As explained above, the BIOS only loads the first 512 bytes of the bootable device, so there isn’t a lot of space for the bootloader to do everything that it needs to do. For this reason, the bootloader is divided in two stages; the main purpose of Stage 1 is to load the Stage 2 binary, usually located in the same device where the Stage 1 is, and jump to it.

The Stage 2 binary is usually stored in the device using some standard file system, like FAT32 or ext4, so the Stage 1 code should be able to at least locate the file, and read the sectors where the file contents are stored. Furthermore, since the Stage 1 code must be stored in the first 512 bytes, and many file systems also use this region for storing data structures, the actual space left for the code (including things like strings for error messages) could be closer to 450 bytes.

In the NAOS bootloader, the Stage 2 binary is stored in the root directory of a FAT12 file system as stage2.bin2. The data structure used for storing the volume information on FAT file systems is called the Extended BIOS Parameter Block (EBPB), and its size and elements change depending on the FAT version. For FAT12 and FAT16, this structure is the DOS 4.0 EBPB, which is 51 bytes wide and should be placed at offset 0xB of the disk.

The first 2 bytes of the disk are a short jump to the actual entry point of the bootloader, right after the EBPB.

3.1. Register initialization

The very first thing that the bootloader does is setting up the Data Segment (DS), Extra Segment (ES) and Stack Segment (SS) registers to 0x0000. Since we can’t MOV immediate values, we need to first clear AX, and then use that to set these segment registers.

Next, the Stack Pointer (SP) is set to 0x7C00, the physical address where the BIOS loaded us; since the stack grows downwards, we will use the memory region adjacent to us for the stack3. At first glance, this address might seem incorrect, since the first PUSH would overwrite the first 2 bytes of our Stage 1 binary; this is incorrect, because the PUSH instruction decreases the Stack Pointer before writing the pushed value to the address at SS:SP (see Intel SDM, Vol. 1, p. 1235).

Next, a far jump is performed to ensure that the Code Segment (CS) and Instruction Pointer (IP) are set to 0000:7C004, rather than 07C0:0000, which is used by some BIOSes.

3.2. Reading disk information with the BIOS

Although the EBPB is defined in that same Stage 1 binary with some basic information, it’s better to ask the BIOS for the actual disk information. This can be done by using the disk BIOS interrupt (0x13) with the AH register set to 0x08.

    ; Set Carry Flag (CF), set AH to "Read Drive Parameters", and
    ; call the "Disk" BIOS interrupt.
    stc
    mov     ah, 0x8
    int     0x13

    ; Jump if the Carry Flag was cleared by the BIOS.
    jnc     .success

.error:
    ; ...

.success:
    ; Read relevant values, mainly from DH and CX.

Specifically, the bootloader writes the Sectors per track and Head count values returned by the BIOS into the EBPB.

3.3. Loading the Stage 2 binary

In order for the Stage 1 to load the Stage 2 binary, it needs to find it first. Specifically, it needs to find the directory entry of the Stage 2 binary by traversing the FAT12 root directory, and then obtain the first cluster index where the actual contents of the Stage 2 file are stored.

Then, after knowing that first cluster number, it traverses the linked list of cluster indexes that is stored in the File Allocation Table (FAT), reading each cluster into memory.

If the reader is interested in more information about the FAT file system, and how this part should be implemented, see my Understanding the FAT file system article. However, it’s worth noting that the actual operation for reading from the disk is performed using the disk BIOS interrupt (0x13) with the AH register set to 0x02.

3.4. Jumping to the Stage 2 code

Once all the clusters of the Stage 2 binary have been read, the Stage 1 binary jumps to the address where it was loaded, using a far jump. Since the Stage 2 binary was loaded into the address at ES:BX, the bootloader should be able to just jump there.

; NOTE: Invalid.
jmp     es:bx

However, there isn’t a JMP instruction that allows the programmer to do a far jump to a segment and offset contained in registers. However, it allows the programmer to specify a pointer to a 32-bit memory location where the segment and offset are specified.

my_addr: resw 2

mov     word [my_addr + 0], bx
mov     word [my_addr + 2], es
jmp     far [my_addr]

However, this is not the best method, since the opcodes for these instructions take up many bytes, and 4 extra bytes are needed for the buffer. Alternatively, one can use two PUSH instructions and a far RET to accomplish the same thing, without using an intermediate buffer, and with shorter instructions.

push    es
push    bx
retf            ; Alternatively: RET FAR

The far jump method used a total of 16 bytes, while the far return method used only 3. This wouldn’t make much difference in a normal binary, but these extra 13 bytes can become really useful as the Stage 1 binary grows.

Note that, as mentioned, the jump is made to the first byte of the Stage 2 binary, not to the entry point of an ELF file, so the Stage 2 binary must be built with this in mind.

Footnotes:

1

Actually, the BIOS starts by loading the first 512 bytes, and then checks for the boot signature.

2

Since FAT12 uses the 8.3 filename convention, the actual stored name, the one that the Stage 1 should look for, is STAGE2 BIN.

3

Keep in mind that the free memory region before the Stage 1 binary usually goes from physical address 0x0500 to 0x7BFF, and going below that 0x0500 address would overwrite the BIOS Data Area (BDA). See the OSDev wiki for more information.

4

This address is meant to illustrate the difference between the two main possible values set by the BIOS, but the bootloader jumps to the adjacent instruction, which would be at an offset like 0x7C46.