Up | Home

The design of a simple bootloader

Table of Contents

1. Introduction

This article explains the code and overall design of 8dcc’s bootloader, which was originally designed for the NAOS kernel. This tiny bootloader, written from scratch, 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.

A 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 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 the MOV instruction doesn’t support assigning immediate values to segment registers, the AX register is first cleared and then used to set those segment registers.

Next, the Stack Pointer (SP) is set to 0x7C00, the physical address where the BIOS loaded the Stage 1 binary; since the stack grows downwards, the memory region right below this address can be used 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.

4. Stage 2

The Stage 2 binary is a flat binary (i.e. it is not an ELF file) located in the root directory of the FAT12 file system of the Stage 1. One of the main goals of Stage 1, because of its binary size limitations, is to search for this Stage 2 binary, load it into memory, and jump to it.

Therefore, the Stage 1 code should know where to load the Stage 2 binary, and the Stage 2 code should know the address where it’s going to be loaded. This consensus is achieved through two STAGE2_ADDR macros, defined in two different files, but that must match. The first one is defined in bootloader/src/include/boot_config.asm (used by Stage 1) and the other in bootloader/linker/boot_config.ld (used when linking Stage 2).

Once the Stage 2 binary is loaded, it can perform all of the bootloader initialization without worrying about size limitations. First, the Stage 2 shows an information message using the BIOS I/O functions, and then it tries to enable the A20 line.

4.1. Enabling the A20 line

The A20 line, which is disabled by default, limits the addressable memory to 1 MiB, and should be enabled by the bootloader before transferring control to the kernel, or simply for switching to protected mode. In order to understand what the A20 line is, and how it can be enabled, it’s important to know a bit of processor history, starting with how segmentation works in 16-bit real mode.

The Intel 8086 processor had 20 address lines, numbered A0 to A19; with these, the processor could access 220 bytes, or 1 MiB. Internal address registers of this processor were 16 bits wide. To access a 20-bit address space, an external memory reference was made up of a 16-bit offset address added to a 16-bit segment number5, shifted 4 bits to the left so as to produce a 20-bit physical address.

The following code shows how the real address would be calculated from a segment and an offset.

; Set data segment (DS) through intermediate register (AX).
mov     ax, 0x13A5
mov     ds, ax

; Write offset to the source index (SI), since not all registers can
; be used for addressing.
mov     si, 0x3327

;   13A5   (Segment: DS)
; +  3327  (Offset: SI)
; -------
;   16D77  (Address)
mov     ax, [ds:si]

Another important detail about this old processors is that, since they only had 20 address lines, addresses over 1 MiB caused the actual address to wrap around. For example, F800:8000, which should translate physical address 0x00100000, actually translates to address 0x00000000, since the 21st bit is discarded.

As processors evolved, starting from the Intel 80286, they were able to address more than 1 MiB of memory. However, for backwards compatibility, they were still supposed to emulate the behavior of a 8086 processor when booting up, which meant that they had to force this wrap-around behavior, since some programs depended on this. To control this wrap-around behavior, a logic gate was inserted in the A20 line between the processor and system bus, which got named Gate-A20.

This logic gate was supposed to be controlled from software, originally through the Intel 8042 keyboard controller. Since then, other more efficient methods are available, but they might not all work, so it’s best to try as many of them as possible. Without getting into much detail, these are the methods used in the bootloader, starting with the most likely to work:

  1. Check if the A20 line was already enabled. This is done by comparing a known value at some address with the value located 1 MiB higher; if they match, it’s safe to assume that it wrapped around, so the A20 line is disabled.
  2. Try to enable it through the BIOS. This is done through BIOS interrupt 0x15.
  3. Try to enable it through the original keyboard method.

If the bootloader can’t enable the A20 line, it shows an error message and stops.

4.2. Loading the GDT

Before switching to protected mode, the Global Descriptor Table (GDT) has to be loaded.

4.3. Switching to protected mode

Before transferring access to the kernel, the bootloader has to switch to protected mode.

5. Building and debugging the disk image

The build process of the disk image has a few steps that are worth mentioning here. The target of the build process is to obtain a file that can be flashed into a device, making it bootable with our bootloader.

First, the assembly sources are assembled using nasm into a 32-bit ELF object file. This object file is then linked into a flat binary using an i686 cross-compiler, using an appropriate linker script. Furthermore, the ELF object file produced by NASM can be linked into an ELF binary, which can be used for debugging, as shown below. The following diagram explains the build process of an assembly file.

bootloader-assembly-build.svg

Once the Stage 1 and Stage 2 binaries are built, they can be inserted into the filesystem of the final image. First, an empty image is created with dd, and then the filesystem itself is created with mkfs.fat. Then, the mcopy (from the mtools package) is used to copy the files into the image that was just created.

To make the device bootable, however, we need to make sure that the Stage 1 image is placed in the first sector of the image. Some of the FAT12 filesystem information, such as the Bios Parameter Block (BPB), is also located on this sector, though, so we will only copy certain chunks of the Stage 1 binary. To do this, the copy-fat12-boot.sh script is used, which just calls dd with some special flags. For more information on the FAT12 file system, check out my Understanding the FAT file system article.

For debugging the image, as noted above, the ELF binaries can be used. They can both be loaded into GDB with the add-symbol-file commands, or used with many tools like addr2line. For more information on debugging, check out the bootloader repository’s, linked above.

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.

5

For more information on 16-bit segmentation, see this article by Gary Burt.