A bootloader is a small but extremely important piece of software that helps a computer boot an operating system (OS). Creating one is a challenging task even for a skilled low-level developer. Thatโs why Apriorit driver development experts decided to share their experience on the subject.
In this article, we overview the theory of system loading and show you how to write a bootloader. You can check out the solution we describe in this tutorial in our GitHub repository.
This article will be useful for developers working with high-level languages like C and C++ who need to learn how to develop a bootloader.
Contents:
Stage 1: Preparing for bootloader development
Letโs start with a quick overview of bootloader development basics.
A bootloader is a piece of software located in the first sector of a hard drive where system booting starts. This sector is also known as the master boot record (MBR). Computer firmware reads the data contained in this first sector and processes it to the system memory when the machine is powered up. When the firmware finds the bootloader, it loads it and the bootloader initiates the launch of the OS.
The boot sector is usually the first sector of the disk. This isnโt obligatory for a modern boot system, but most developers place the bootloader in the first sector. So for now, weโll stick to the first sector as well. But keep in mind, that a boot sector is required only for BIOS-based systems.
Choosing a firmware interface: UEFI vs BIOS
You can develop a bootloader for a Basic Input/Output System (BIOS) or Unified Extensible Firmware Interface (UEFI). A BIOS is well-known computer hardware supported by all devices. UEFI is a modern BIOS replacement provided on modern hardware that gives developers many more options for low-level development.
Key advantages of working with UEFI instead of BIOS:
- The ability to work in 32/64 mode allows you to access more processor functionalities, while BIOS works in 16-bit mode only.
- No boot disk size limitations allow you to use any disk to boot the system.
- Writing code directly in C using complete development environments such as EDK2 eliminates the need to study assembler or mix high-level and low-level code.
- Secure boot verifies that a device boots using only trusted software that has electronic signatures.
But despite all these advantages, in this tutorial, weโll be developing a BIOS bootloader from scratch. The first reason for this is because BIOS is supported by a wider range of hardware. Furthermore, almost all old devices support BIOS only. Secondly, all UEFI-compatible systems can behave like BIOS systems in legacy mode, so youโll be able to run our bootloader on UEFI systems as well.
Does your project require low-level development?
Entrust your project to Aprioritโs skilled team. We will help you develop and test low-level projects of any complexity, meeting all of your requirements.
Understanding the system booting process
Next, we need to gain a general understanding of the system booting process. This is how components interact with each other when you boot the system:
Figure 1. The system booting process
- The BIOS reads the first sector of the hard disk drive (HDD).
- The BIOS passes control to the MBR located at the address 0000:7c00, which triggers the OS booting process.
After that, the booting process is over and the OS starts.
Choosing a language in which to develop the bootloader
Most commonly used languages use a virtual machine to convert intermediate code into language understood by the processor. You can execute that intermediate code only after itโs converted. The only exceptions are hypothetical cases when you implement all required runtime code by yourself using some set of native languages.
When developing a bootloader, you need a language that doesnโt depend on the runtime. You canโt use runtime-dependent languages like Java and C# because they produce intermediate code after compilation.
To avoid this challenge, weโll use C++ as the main language for low-level programming in this bootloader development tutorial.
Also, knowing the basics of how to work with an assembler is a great advantage when writing a bootloader. During the initial stages of computer operation, the BIOS takes control of the machine hardware via functions called interrupt calls, which are written in an assembler language. Itโs not obligatory to know the assembler language when building a bootloader, since we can mix low-level language commands and high-level constructions. But knowing it is essential for debugging the written bootloader.
Selecting the tools
To be able to mix high-level and low-level code and write your own bootloader, youโll need at least three tools:
- Compiler for the assembler
- C++ compiler
- A linker
A processor functions in 16-bit real mode with certain limitations and in 32-bit safe mode with full functionality. When you turn on a computer, its processor operates in real mode. Thatโs why building a program and creating an executable file requires an assembler compiler and linker that can work in 16-bit mode.
A C++ compiler is required only to create *.obj files in the 16-bit real mode.
Note: The latest compilers are not suitable for our task, as theyโre designed to run in 32-bit safe mode only.
After testing several 16-bit compilers, we chose Microsoft compilers and linkers for this tutorial. We used them to build all low-level language code examples and other cited code. The Microsoft Visual Studio 1.52 package already contains what we need: a compiler and a linker for assembler and C++.
Here are the linkers and compilers you can use to develop a custom bootloader instead of the tools we use in the tutorial:
Assembler compilers | C/C++ compilers | Linkers |
ML 6.15
16-bit compiler by Microsoft | CL
16-bit compiler | LINK 5.16
16-bit linker for creation of *.com files |
DMC
free compiler by Digital Mars | DMC
free compiler by Digital Mars | LINK
free linker designed to work with the DMC compiler |
TASM
16-bit compiler by Borland | BCC 3.5
16-bit compiler by Borland | TASM
16-bit linker for creation of *.com files by Borland |
Note: If you have any problems using the cl.exe file provided in our GitHub repository, you may try using the DMC compiler instead.
Read also
How to Develop a Windows Minifilter Driver to Back Up Data
Examine a step-by-step minifilter driver development tutorial with our expert advice to get ready for your next low-lever project.
Stage 2: Developing the bootloader
Weโre going to build a basic bootloader that performs three simple tasks:
- Loads bootloader instructions from the 0000:7c00 address to the system memory.
- Calls the BootMain function written in a high-level language.
- Displays a โHello worldโ message on the screen.
The architecture of our bootloader looks like this:
Figure 2. Bootloader architecture
The first element is StartPoint
. This entity is written in an assembler language because high-level languages lack the required instructions. StartPoint
instructs the compiler to use a specific memory model and lists the addresses for loading to RAM after data from the disk is read.
BootMain
is an entity similar to the main
element written in a high-level language that takes control right after StartPoint
.
Finally, CDisplay
and CString
come in. Their role is to display the message. As you can see from Figure 2, they arenโt equal, as CDisplay
uses CString.
Setting up the environment
To develop our bootloader, we need compilers, linkers, and their dependencies. Weโll also use Microsoft Visual Studio 2019 to make the process more convenient.
To start configuring the environment, we need to create a project using the Makefile Project template. In Microsoft Visual Studio, choose File > New > Project > General and select Makefile Project. Then click Next.
Figure 3. Creating a new project in Microsoft Visual Studio 2019
BIOS interrupts and screen cleaners
Before our bootloader can display the message, the screen must be cleared. Letโs use a BIOS interrupt for this task.
The BIOS provides various interrupts that allow for interacting with computer hardware: input devices, disk storage, audio adapters, and so on. An interrupt looks like this:
int [number_of_interrupt];
Here, the number_of_interrupts
is the interrupt number. In this tutorial, youโll need the following interrupts:
int 10h, function 00h
โ This function changes the video mode and thus clears the screenint 10h, function 01h
โ This function sets the type of the cursorint 10h, function 13h
โ This function concludes the whole routine by displaying a string of text on the screen
Before you call an interrupt, you must first define its parameters. The ah
processor register contains the function number for an interrupt, while the rest of the registers store other parameters of the current operation. For example, letโs see how the int 10h
interrupt works in an assembler. You need the 00h function to change the video mode, which will result in a clear screen:
mov al, 02h ; here we set the 80x25 graphical mode (text)
mov ah, 00h ; this is the code of the function that allows us to change the video mode
int 10h ; here we call the interrupt
Mixing high-level and low-level code
One of the advantages of the C++ compiler is that it has an inbuilt assembler translator, which allows you to write low-level code in a high-level language. Assembler instructions written in high-level code are called asm insertions. Theyโre marked with the introductory string _asm
followed by a block of assembler instructions enclosed in braces. Hereโs an example of a low-level language code insertion:
__asm ; this is a keyword that introduces an asm insertion
{ ; the beginning of a block of code
โฆ ; some asm code
} ; the end of the block of code
Now we combine the C++ code with the assembler code that clears the screen to illustrate the mixed code technique:
void ClearScreen()
{
__asm
{
mov al, 02h ; here you set the 80x25 graphical mode (text)
mov ah, 00h ; this is the code of the function that allows us to change the video mode
int 10h ; here you call the interrupt
}
}
With this knowledge of the OS booting process, we can move on to developing the elements of our bootloader.
Read also
How to Develop a Minimal OS for Raspberry Pi 3 in Rust
Embedded systems have limited computing resources, so software for them needs to be extremely efficient. Discover how to develop such software, which tools to choose, and what to prioritize in its design.
Implementing bootloader structure elements
After you learn more about BIOS interrupts and mixed code, you can start implementing bootloader components. Letโs start with StartPoint.asm:
;------------------------------------------------------------
.286 ; CPU type
;------------------------------------------------------------
.model TINY ; memory of model
;---------------------- EXTERNS -----------------------------
extrn _BootMain:near ; prototype of C func
;------------------------------------------------------------
;------------------------------------------------------------
.code
org 07c00h ; for BootSector
main:
jmp short start ; go to main
nop
;----------------------- CODE SEGMENT -----------------------
start:
cli
mov ax,cs ; Setup segment registers
mov ds,ax ; Make DS correct
mov es,ax ; Make ES correct
mov ss,ax ; Make SS correct
mov bp,7c00h
mov sp,7c00h ; Setup a stack
sti
; start the program
call _BootMain
ret
END main ; End of program
BootMain is the main function that serves as the starting point of the program. This is where the main operations take place. For our bootloader, this function looks like this:
// BootMain.cpp
#include "CDisplay.h"
#define HELLO_STR ""Hello, worldโฆ", from low-level..."
extern "C" void BootMain()
{
CDisplay::ClearScreen();
CDisplay::ShowCursor(false);
CDisplay::TextOut(
HELLO_STR,
0,
0,
BLACK,
WHITE,
false
);
return;
}
The CDisplay class handles interactions with the screen. It consists of the following methods:
- ShowCursor controls the cursor manifestation on the display. It has two values: show and hide, which enable and disable the cursor manifestation respectively.
- TextOut produces the text output, i.e. displays a string on the screen.
- ClearScreen clears the screen by changing the video mode.
Hereโs the implementation of the CDisplay class:
// CDisplay.h
#ifndef __CDISPLAY__
#define __CDISPLAY__
//
// colors for TextOut func
//
#define BLACK 0x0
#define BLUE 0x1
#define GREEN 0x2
#define CYAN 0x3
#define RED 0x4
#define MAGENTA 0x5
#define BROWN 0x6
#define GREY 0x7
#define DARK_GREY 0x8
#define LIGHT_BLUE 0x9
#define LIGHT_GREEN 0xA
#define LIGHT_CYAN 0xB
#define LIGHT_RED 0xC
#define LIGHT_MAGENTA 0xD
#define LIGHT_BROWN 0xE
#define WHITE 0xF
#include "Types.h"
#include "CString.h"
class CDisplay
{
public:
static void ClearScreen();
static void TextOut(
const char far* inStrSource,
byte inX = 0,
byte inY = 0,
byte inBackgroundColor = BLACK,
byte inTextColor = WHITE,
bool inUpdateCursor = false
);
static void ShowCursor(
bool inMode
);
};
#endif // __CDISPLAY__
// CDisplay.cpp
#include "CDisplay.h"
void CDisplay::TextOut(
const char far* inStrSource,
byte inX,
byte inY,
byte inBackgroundColor,
byte inTextColor,
bool inUpdateCursor
)
{
byte textAttribute = ((inTextColor) | (inBackgroundColor << 4));
byte lengthOfString = CString::Strlen(inStrSource);
__asm
{
push bp
mov al, inUpdateCursor
xor bh, bh
mov bl, textAttribute
xor cx, cx
mov cl, lengthOfString
mov dh, inY
mov dl, inX
mov es, word ptr[inStrSource + 2]
mov bp, word ptr[inStrSource]
mov ah, 13h
int 10h
pop bp
}
}
void CDisplay::ClearScreen()
{
__asm
{
mov al, 02h
mov ah, 00h
int 10h
}
}
void CDisplay::ShowCursor(
bool inMode
)
{
byte flag = inMode ? 0 : 0x32;
__asm
{
mov ch, flag
mov cl, 0Ah
mov ah, 01h
int 10h
}
}
The CString class works with strings. The value of the string it contains will be used by the CDisplay class. Letโs implement the Strlen method of this class. This method passes a pointer to a string as its parameter, counts the number of characters in the obtained string, and returns the resulting number. The implementation looks like this: