This is not a guide on how to make a BIOS bootloader. There are plenty of those on the internet. Instead, I am going to list out a couple of mistakes I made while making my own bootloader and tips to avoid or debug similar problems. To start off, we need to start with the drive and the booting system. Then, I will talk about good practices for setting up the bootloader environment. I will also talk about common mistakes when it comes to loading the kernel. Generally, the ultimate tip would be to simply not take anything for granted. Note, I am only going to cover x86 systems.
A mistake that new developers may make with their BIOS bootloaders is that they do not account for the difference in how the BIOS loads from different drives. Different drives may have different bytes per sector and the BIOS may load from them differently. When a PC boots up, the BIOS will load a bootsector in 2 different ways depending on the type of drive that it is booting from. Through some process (usually a search through the connected drives), the BIOS will look for a drive that can be booted from. A bootable drive is a hard drive with the magical string (0x55
, 0xaa
) at the end of the first sector (most typically the first 512 bytes). Once the BIOS detects the bootable drive, it will load a single sector from the drive. The process is simplest for a hard drive. If the BIOS is loading from a hard drive, it will simply load the first sector at the address 0x7c00
(the first 512 bytes). If the BIOS is loading from a floppy drive or a CD/DVD, then the process is slightly more complicated. Instead of loading the first sector in the drive, the BIOS will load the first sector of a boot file that is specified on the ISO9660 filesystem present on the drive. The BIOS will search for the boot file entry on the filesystem. Then, the BIOS will load the first sector (typically 2048 bytes) of the boot file. In both cases, the BIOS will load the bootsector at the physical address 0x7c00
, and then start execution at the same address. The bootloader should make sure to differentiate between the two ways of loading the bootsector. The bootloader should most definitely make no assumptions about the number of bytes per sector that is in each drive. To detect the number of bytes per sector, the bootloader can use the BIOS interrupt 0x13
to get information about the drive that it loads from.
[bits 16]
; Assume register dl contains the drive number
; Assume segment ds = 0
mov ah, 0x48
mov si, [drive_parameters]
int 0x13
...
drive_parameters:
drive_parameters:size: dw 0x1e
dw 0
dd 0
dd 0
dd 0
dd 0
dd 0
drive_bytes_per_sector: dw 0 ; The bytes per sector
dd 0
The code above is a rough example of how one might load the bytes per sector using the BIOS. Again, once the bytes per sector is loaded, the developer must also take it into account when they try loading other parts of the drive. For example, when loading the kernel or filesystems, the bootloader should use the bytes per sector, as the BIOS can only load in sectors. If the BIOS loads the bootsector from a CD/DVD or floppy drive, then the developer should not assume that they can locate the bootloader at a fixed sector. The developer may have to manually locate the bootloader on the drive. In this case, the drive must contain an ISO9660 filesystem. Some developers may try to parse the filesystem in the bootsector. My recommendation is that the developer use a convention for storing information about the bootloader directly on the bootsector, such as el-torito. Such conventions can only be applied during the packaging time of the bootloader. As a developer, in order to create an ISO9660 filesystem, one must use a tool such as mkisofs
or xorriso
(on mac, you can also use hdiutil
). If specified, the tools will place the first sector location and length in sectors of the bootloader in the bootsector. A possible way to package the bootloader using mkisofs
would be
mkisofs -no-emul-boot -boot-info-table -boot-load-size 4 -b /path/to/bootloader.bin -V "<osname>" /path/to/root/of/filesystem -o /path/to/output.cdrom
The above command would create a CD image at /path/to/output.cdrom
with /path/to/bootloader.bin
as the bootloader and /path/to/root/of/filesystem
as the root of the ISO9660 filesystem. Again, the point of doing such things is to make it convenient to load the rest of the bootloader, as only the first sector is loaded by the BIOS. The el-torito specification in particular requires some empty space at the beginning of the bootsector in order for the packaging tool to load in the positions and lengths of the bootloader.
[bits 16]
; Sets CS and jumps to code after the el-torito parameters
jmp 0:next
times 8-($-$$) db 0 ; The el-torito parameters start at an offset of 8 bytes
primary_volume_descriptor_position: dd 0
boot_location: dd 0
boot_length: dd 0
torito_checksum: dd 0
times 40 db 0
next:
...
The packaging tool will fill in the parameters (at fixed offsets). The el-torito parameters will only be used for ISO9660 filesystem drives, which are only floppy drives and CD/DVD. For hard drives, the developer can simply use the first couple of sectors as storage for the bootloader.
Once the bootsector has loaded and starts execution, it is crucial that the developer sets up the environment for the bootsector to run in. There are mainly 2 things to consider. First would be the segment registers. Second would be the stack. The segment registers control how addressing works on the system. In order to understand segment registers, the developer must first understand how addressing works. There are two addresses in play. The first type is the logical or virtual address (despite varied usage, both terms mean the same thing). The second type is the physical address. The logical address is whatever address is processed by the CPU. In other words, it is whatever address the developer uses in their code. The CPU transforms the logical address using the segment registers and paging into a physical address. The physical address is the actual part of the memory that the CPU will reference. The bootsector will always boot up in real mode. In real mode, the logical to physical address relation will look like
physical_addr = 16*segment_register + logical_addr
As the logical address is used for every part of the code, from the instruction pointer to data retrieval, it is important to set it at the beginning of the bootsector. There are really 4 segment registers that truly matter. The cs
register affects the instruction pointer, or the position of execution in memory; the ds
and es
registers affect any memory reference, or in the loading and setting of memory; and the ss
register affects the stack. For simplicity, I recommend that the developer set all of these segment registers to 0 initially.
[bits 16]
; Sets CS and jumps to code after the el-torito parameters
jmp 0:next
times 8-($-$$) db 0 ; The el-torito parameters start at an offset of 8 bytes
primary_volume_descriptor_position: dd 0
boot_location: dd 0
boot_length: dd 0
torito_checksum: dd 0
times 40 db 0
next:
; Setup segment registers
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
...
Once the segment registers are set, the developer is ensured that they can safely execute their code in the bootsector. I will advise the developer to make sure that they familiarize themselves with paging later on when the bootloader tries to enter protected and long mode. The segments in protected and long mode will have a non-linear relationship with the logical address. Paging will also be non-linear. The developer should make sure to properly set up both for their system before entering protected or long mode. Once the segment registers have been set, the developer should not forget to set the stack. On most systems, the stack will grow downward. So, we need to make sure that the stack will not overlap with the code. I recommend to initially move the stack to 0x7c00
, as the stack will be just below the code. So, the stack will never overlap with the code (assuming proper usage of stack operations).
There are many ways to load the kernel from the bootloader, each with their own advantages and setbacks. From my own experience, I know of two ways the bootloader can load the kernel. First, the bootloader can load the kernel using the BIOS functions. Second, the bootloader can load the kernel with its own secondary storage drivers. In both cases, I assume that the kernel must be loaded at some address above 1mib
. So, the developer must make sure to enable the a20 line on the memory bus. I will not be covering how to do that here because other tutorials have done it with great detail. Instead, I will discuss the difficulties that arise because of where the kernel must be loaded. Since the kernel is loaded above the 16 bit addressing limit of real mode, the bootloader must be in protected mode when it moves or loads the kernel to the destination address. If the bootloader uses the BIOS to load the kernel, it must somehow overcome addressing limit. Since the BIOS can only load within the 16 bit addresses, either the kernel must be loaded within the 16 bit addresses and then later moved above, or the kernel must be loaded in some other manner. With the former, the kernel can switch between real mode and protected mode to first load the kernel, or parts of it, and then to move the kernel to higher addresses in protected mode. The difficulty in this approach is that switching between real and protected mode can be a tedious and buggy process. The developer must be careful to consider every step of the switch such that no information is lost during the switch and the environment or code is not overwritten. Moreover, the approach of switching between real and protected mode does not yield great performance, since the switch itself is rather slow. For the approach of implementing a custom driver, the developer must somehow make their own drivers, which requires many parts. Typical secondary storage drivers require support for interrupts, filesystems, and other components, that may not be worth the hassle for a simple bootloader. GRUB, the linux bootloader, implements its own drivers, but it is also large and complex. For the average developer, it may be overwhelming to learn about all the different kinds of secondary storage, the different conventions, how to detect different storage, etc. So, although the final process of simply being able to load the kernel without switching between real mode and protected mode may be there, it is also the case that more work will be required in other areas. Both approaches are fine. If the developer wishes to create a more complete and faster bootloader, it may be worth it to develop all the drivers necessary for the bootloader. If the developer instead wishes to have a simple loading of the kernel, the switching between real and protected mode would be the fastest to develop. It all depends on what the goal of the developer is.
I have just discussed the considerations and difficulties of making a BIOS bootloader, and how one might tackle them. In each and every case, the same principle holds constant; do not assume anything. The bootloader must take every edgecase and bug into account. The developer must also be clear on what they want to do with the bootloader. Will the bootloader just load a kernel and that's it? Or will it have a variety of functions like modern bootloaders. In every case, make no assumptions.
cool
•.,¸,.•*`•.,¸¸,.•*¯ ╭━━━━╮ •.,¸,.•*¯`•.,¸,.•*¯.|:::::::::: /\___/\ •.,¸,.•*¯`•.,¸,.•* <|:::::::::(。 ●ω●。) •.,¸,.•¯•.,¸,.•╰ * し------し--- J
Thanks for the auspicious writeup. It in truth was once a amusement account it. Glance complicated to more brought agreeable from you! However, how can we be in contact?
The guidelines of posting are rather simple. Be respectful and engage honestly. I am not against swearing, but avoid using slurs or generally hateful messages. Use your common sense. Comments will not be posted until they are approved by an admin.