banner



How To Create A Bootloader

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

Stage 2: Developing the bootloader

Stage 3: Assembling the bootloader

Stage 4: Testing the bootloader on a VM and real hardware

Tools for bootloader debugging

Conclusion

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:

Key advantages of working with UEFI instead of BIOS

  1. The ability to work in 32/64 mode allows you to access more processor functionalities, while BIOS works in 16-bit mode only.
  2. No boot disk size limitations allow you to use any disk to boot the system.
  3. 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.
  4. 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.

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:

The system booting process

Figure 1. The system booting process

  1. The BIOS reads the first sector of the hard disk drive (HDD).
  2. 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:

  1. Compiler for the assembler
  2. C++ compiler
  3. 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.

Stage 2: Developing the bootloader

We're going to build a basic bootloader that performs three simple tasks:

  1. Loads bootloader instructions from the 0000:7c00 address to the system memory.
  2. Calls the BootMain function written in a high-level language.
  3. Displays a "Hello world" message on the screen.

The architecture of our bootloader looks like this:

Program architecture

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.

The system booting process

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 screen
  • int 10h, function 01h – This function sets the type of the cursor
  • int 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.

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         ); }; 

0 Response to "How To Create A Bootloader"

Post a Comment

Iklan Atas Artikel

Iklan Tengah Artikel 1

Iklan Tengah Artikel 2

Iklan Bawah Artikel