A bootloader is a program that is responsible for loading an operating system into memory when a computer is turned on. It is typically the first program to run when a computer is powered on, and it is responsible for initializing hardware and loading the operating system kernel into memory.
In this tutorial, we will go through the basics of how a bootloader works, how to write a simple bootloader, and how to integrate it with an operating system.
📘 Understanding the Boot Process
Before we dive into writing a bootloader, it is important to understand how the boot process works. When a computer is turned on, the CPU starts executing code from a fixed location in memory called the reset vector. This location is typically set by the hardware manufacturer and cannot be changed.
The code at the reset vector is typically a small piece of firmware known as the BIOS (Basic Input/Output System) or UEFI (Unified Extensible Firmware Interface). The BIOS or UEFI is responsible for initializing hardware, performing a power-on self-test (POST), and loading the bootloader.
Once the bootloader is loaded, it is responsible for loading the operating system kernel into memory and jumping to the kernel's entry point.
📘 Writing a Simple Bootloader
Now that we understand how the boot process works, let's write a simple bootloader. We will start with a bootloader that simply prints a message to the screen and hangs.
✔ Setting Up the Development Environment
To develop a bootloader, we will need a few tools:
A text editor for writing code
A compiler for assembling the code into machine language
A linker for linking the machine code into a bootable binary image
A virtual machine or physical computer for testing the bootloader
For this tutorial, we will use the following tools:
Visual Studio Code as the text editor
NASM (Netwide Assembler) as the assembler
QEMU as the virtual machine
We will assume that you have already installed these tools on your system.
✔ Writing the Bootloader Code
Create a new file called bootloader.asm and add the following code:
bootloader.asm
; Set the segment registers
[org 0x7c00]
xor ax, ax
mov ds, ax
mov es, ax
; Print a message to the screen
mov si, message
call print_string
; Hang
jmp $
message db "Hello, world!", 0print_string:
; Print a null-terminated string pointed to by SI
mov ah, 0x0e
.loop:
lodsb
or al, al
jz .done
int 0x10
jmp .loop
.done:
ret
Let's go through this code line by line:
The first line, ; bootloader.asm, is a comment and can be ignored.
The next three lines set the segment registers to zero. This is necessary because the bootloader is loaded into memory at address 0x7c00, which is in the first segment.
The next two lines load the address of the message string into the SI register and call the print_string function to print the message to the screen.
The print_string function prints a null-terminated string pointed to by the SI register using BIOS interrupt 0x10, subfunction 0x0e.
The jmp $ instruction hangs the bootloader by jumping to the current instruction indefinitely.
✔ Assembling and Linking the Bootloader
Now that we have written the bootloader code, we need to assemble and link it into a bootable binary image.
Create a new file called linker.ld and add the following code:
linker.ldENTRY(_start)
SECTIONS {
. = 0x7c00;
.text : {
*(.text)
}
.sig : {
SHORT(0xaa55)
}
}
This linker script sets the entry point to the _start label, sets the location of the code to 0x7c00, and adds a 16-bit signature at the end of the binary image.
Next, open a terminal and navigate to the directory containing the bootloader.asm and linker.ld files. Run the following command to assemble and link the bootloader:
nasm -f bin -o bootloader.bin bootloader.asm -l bootloader.lst -D BOOTLOADER_SECTOR_COUNT=1 -T linker.ld
Let's go through this command line by line:
nasm is the NASM assembler.
-f bin specifies the output format as binary.
-o bootloader.bin specifies the output file name as bootloader.bin.
bootloader.asm is the input file name.
-l bootloader.lst generates a listing file named bootloader.lst.
-D BOOTLOADER_SECTOR_COUNT=1 defines a macro named BOOTLOADER_SECTOR_COUNT with a value of 1.
-T linker.ld specifies the linker script as linker.ld.
✔ Testing the Bootloader
Now that we have assembled and linked the bootloader, we need to test it to make sure it works.
Open a terminal and navigate to the directory containing the bootloader.bin file. Run the following command to start QEMU and boot the bootloader:
qemu-system-x86_64 -drive format=raw,file=bootloader.bin
This command starts QEMU and loads the bootloader.bin file as a raw disk image.
If everything works correctly, you should see the message "Hello, world!" printed to the screen, and the bootloader should hang indefinitely.
📘 Integrating the Bootloader with an Operating System
Now that we have written and tested a simple bootloader, let's integrate it with an operating system.
For the purposes of this tutorial, we will use a simple operating system called tinyos. tinyos is a 16-bit operating system that can be loaded by a bootloader and runs in real mode.
✔ Writing the Operating System Code
Create a new file called kernel.asm and add the following code:
kernel.asm
BITS 16_start:
mov ax, 0x07c0
add ax, 288
mov ss, ax
mov sp, 4096
mov ax, 0x07c0
mov ds, ax
mov si, message
call print_string
cli
.endloop:
hlt
jmp .endloop
message db "Hello from tinyos!", 0print_string:
; Print a null-terminated string pointed to by SI
mov ah, 0x0e
.loop:
lodsb
or al, al
jz .done
int 0x10
jmp .loop
.done:
ret
Let's go through this code line by line:
The BITS 16 directive specifies that the code is 16-bit.
The _start label is the entry point for the kernel.
The next three instructions set up the stack pointer and the stack segment.
The next two instructions set up the data segment.
The next three instructions print a message
using the print_string subroutine.
The cli instruction disables interrupts.
The hlt instruction halts the CPU until the next interrupt.
The jmp .endloop instruction jumps to the label .endloop.
The message variable is a null-terminated string containing the message to be printed.
The print_string subroutine prints a null-terminated string pointed to by SI using the BIOS interrupt 0x10.
✔ Linking the Operating System Code
Create a new file called kernel.ld and add the following code:
kernel.ld
ENTRY(_start)
SECTIONS {
. = 0x0000;
.text : {
*(.text)
}
}
This linker script sets the entry point to the _start label and sets the location of the code to 0x0000.
Next, open a terminal and navigate to the directory containing the kernel.asm and kernel.ld files. Run the following command to assemble and link the kernel:
nasm -f elf -o kernel.o kernel.asm
ld -T kernel.ld -o kernel.bin kernel.o
Let's go through this command line by line:
nasm is the NASM assembler.
-f elf specifies the output format as ELF.
-o kernel.o specifies the output file name as kernel.o.
kernel.asm is the input file name.
ld is the linker.
-T kernel.ld specifies the linker script as kernel.ld.
-o kernel.bin specifies the output file name as kernel.bin.
kernel.o is the input file name.
✔ Creating a Disk Image
To boot the operating system, we need to create a disk image containing both the bootloader and the kernel.
Open a terminal and navigate to the directory containing the bootloader.bin and kernel.bin files. Run the following command to create a disk image:
dd if=/dev/zero of=disk.img bs=512 count=2880
dd if=bootloader.bin of=disk.img conv=notrunc
dd if=kernel.bin of=disk.img conv=notrunc bs=512 seek=1
Let's go through this command line by line:
dd is a command line utility for copying and converting data.
if=/dev/zero specifies that we want to read from the /dev/zero device, which provides an endless stream of null bytes.
of=disk.img specifies the output file name as disk.img.
bs=512 sets the block size to 512 bytes.
count=2880 specifies the number of blocks to read from /dev/zero, which creates a 1.44 MB disk image with all null bytes.
if=bootloader.bin specifies the input file name as bootloader.bin.
conv=notrunc specifies that we don't want to truncate the output file.
if=kernel.bin specifies the input file name as kernel.bin.
bs=512 sets the block size to 512 bytes for the second dd command.
seek=1 skips the first block of the output file, which is the bootloader.
✔ Testing the Operating System
Now that we have created a disk image containing both the bootloader and the kernel, we can test the operating system.
Open a terminal and navigate to the directory containing the disk.img file. Run the following command to start QEMU and boot the operating system:
qemu-system-i386 -fda disk.img
This command starts QEMU with the disk image as the first floppy disk (-fda) and boots the operating system.
If everything went well, you should see the message "Hello, world!" printed on the screen.
Conclusion
In this tutorial, we have covered the basics of writing a bootloader and an operating system kernel in Assembly language. We have also learned how to link the code, create a disk image, and test the operating system using QEMU.
Writing an operating system is a complex task that requires a deep understanding of computer architecture, system programming, and low-level programming. However, by following this tutorial and studying the provided code, you can gain a good understanding of the basics and start exploring more advanced topics.
Thanks for reading, and happy coding!
Understanding Bootloader in Operating System -> Understanding Process Management in Operating Systems