As you know, I’m a technical person, and I always want to know how technology works on the lowest possible level. This approach gives me a better understanding how modern software works, and how to troubleshoot some specific problems – like performance problems in the context of SQL Server.
For that reason, I have already implemented a whole CPU out of simple TTL chips (AND, OR, XOR, NOT) a few years ago. Another area of computer science that has always fascinated me, is the area of OS development. When I want young (already a long time ago), my dream was always to have my own operating system – or at least I wanted to understand the basics of OS development. OS development on its own is really crazy, because you start with just almost nothing in your hands – just an assembler that can generate x32/x64 opcodes. No C library, no other libraries, nothing.
If I remember correctly, I have booted a PC from my first custom written boot sector around 25 years ago – on real x86 hardware, because nobody had an idea about virtualization. But somehow, I lost the interest, or maybe the time, and I didn’t follow that path.
Fast forward to the year 2014: in February 2014, I decided to pick up again OS development and I have done a lot of work around x32 OS development and Protected Mode programming. According to my GitHub repository, I did the first commit of a boot sector on February 13, 2014. But again, with all the other interesting stuff I did that this time frame, I gave up.
And now we do another fast forward to the year 2018: in April 2018, I again decided to pick up my OS development work (or “research”), but this time my goal was a little bit larger: I wanted to have a fully working x64 OS! There are already so many private operating systems on the internet, which are 32 bits. But there are not that many custom written 64 bit operating systems. And this time I have succeeded. Till the end of May 2019, I was able to implement a whole working x64 OS with a lot of different working features:
- Rudimentary Multithreading (across a single core)
- Memory Manager (physical & virtual)
- Heap Manager
- Paging support
- User Mode programs with syscalls into the Kernel
- Spinlocks! 😉
- GUI with mouse support
But after 2019 I again suspended my OS development work. But over the last few weeks I have finally “rebooted” it and want to show you now over the coming months (maybe years?) how to implement all the cool stuff mentioned above – step by step. I want to start this journey today by talking about how a PC boots, and how you can write your own boot sector that prints out a simple message – “Hello World” in that case – what else?
The Boot Process of a PC
Do you have ever wondered what happens in your PC, when you press the start button? A lot of people just take this for granted, but booting into an operating system is a quite complex, complicated process. The start button of your PC is directly wired with the motherboard. When you press the start button, the motherboard receives an electronic signal and forwards it to the PSU (Power Supply Unit).
After receiving this signal from the motherboard, the PSU provides now electrical power to all the components within your PC. When everything is powered up, the PSU notifies the motherboard about it. The motherboard then hands the controls over to the BIOS – the Basic Input/Output System.
The BIOS is a simple firmware (which can be also flashed) which is loaded into memory and provides some basic services to the OS. The BIOS initializes itself with a test called POST – the Power On Self Test. After the POST process, the processor just jumps to the first instruction of the BIOS (which was loaded into memory) and starts executing it.
In the first step the BIOS sets up a so-called Interrupt Vector Table (IVT). An interrupt is a small program, which can be executed from a program. Interrupts can be also raised by hardware components. To give you an example: every time when you press a key on your keyboard, an interrupt is raised, which is processed by a so-called Interrupt Service Routine (ISR). In later stages of our OS, we will also write a lot of different ISRs, which are interacting with hardware components.
One of the most important things about the IVT is the fact, that is is only available in the Real Mode of your CPU (x16). As soon as you switch your CPU into Protected Mode (x32) or Long Mode (x64), the IVT will be not available anymore, and therefore the BIOS can’t provide *any* services to your OS anymore. We will see that problem also later when we leave the convenient Real Mode. At that stage we can’t just write to the screen by calling a simple interrupt – we must write directly to the video memory and deal with a lot of other aspects to be able to print out characters on the screen…
After the BIOS is initialized, it calls the interrupt 19 by executing the assembly instruction INT 19. This interrupt starts the boot processing. When INT 19 is raised by the BIOS, the BIOS tries to locate a boot loader on a boot device. A boot device can be a floppy disk, a hard disk, a CD ROM drive, a USB stick, etc. – you know all these options quite well. But how it locates a valid boot loader on a boot device?
The answer is quite simple: the BIOS just reads the first sector (512 bytes) from a configured boot device and checks if the last 2 bytes on that sector contains the boot signature – the hex values 0xAA55. If that magic value is found, the whole sector is copied into the main memory beginning at the memory location 0x7C00. And then the BIOS will just perform a jump to that memory location and starts executing the loaded boot sector code. Our boot loader – our OS – has now the full control over the CPU and the PC!
Your first Boot Loader
After we know now how a PC is booted, let’s write and execute now our first boot loader. What you are doing in your boot loader is up to your imagination. The only limitation is that you have only 512 bytes available. More is not possible, because the BIOS only loads the *first* sector of the boot device into memory. Additional developed code is still stored on your boot device and can’t be executed. You can only execute code that was loaded into main memory.
Normally a boot loader sets up a simple environment and loads a so-called Second Stage Boot Loader from the boot device into memory, which is then booting the whole OS. This is done, because you can’t just boot a whole OS (especially x64) within 512 bytes. I’m also applying this approach to my OS. My OS is booted in 3 logical steps:
- The boot sector code from 0x7C00 start execution (16 bit code), and loads the Second Stage Boot Loader (32 bit code) from the boot device to a well-known memory location. Afterwards the boot sector code sets up the data structures necessary to switch the CPU into Protected Mode (x32). After the CPU is switched to Protected Mode, the boot sector code starts executing the Second Stage Boot Loader code by jumping to the well-known memory location.
- The Second Stage Boot Loader takes control by loading the x64 Kernel code into memory. It then sets up the necessary data structures to be able to switch the CPU into Long Mode (x64) – like Page Tables etc. When CPU entered Long Mode, it jumps to the Kernel code and start its execution.
- The x64 Kernel takes control and sets up the OS itself by loading some device drivers (keyboard, screen, mouse) and initializing some processes (Memory Manager, Heap Manager, etc.).
You could also switch a CPU directly from the boot loader code into Long Mode within 512 bytes. It’s possible, but not really recommended, because by introducing a Second Stage Boot Loader you can just do more interesting stuff during your boot process – like detecting the memory layout of your machine etc. Today I just want to show you a few simple boot loaders, not complicated ones – just to give you an idea how everything works. Let’s have a look at the following assembly code:
; Write Text in Teletype Mode: ; AH = 0E ; AL = ASCII character to write ; Set the TTY mode MOV AH, 0xE MOV AL, 'H' INT 0x10 MOV AL, 'e' INT 0x10 MOV AL, 'l' INT 0x10 INT 0x10 MOV AL, 'o' INT 0x10 MOV AL, ' ' INT 0x10 MOV AL , 'W' INT 0x10 MOV AL, 'o' INT 0x10 MOV AL, 'r' INT 0x10 MOV AL, 'l' INT 0x10 MOV AL, 'd' INT 0x10 MOV AL, '!' INT 0x10 JMP $ ; Jump to current address = infinite loop ; Padding and magic number TIMES 510 - ($-$) DB 0 DW 0xaa55
It’s the easiest boot loader code on earth. It just prints out the string “Hello World!”. You can write to the screen by using the interrupt 10 – provided by the BIOS. We are still here in Real Mode, and therefore we can make the use of interrupts. We don’t have to deal with the hardware components directly. If you want to write text to the screen, you must specify the value 0xE in the register AH, and the actual ASCII code of the character to be printed in the register AL. We are just performing for each character a call to the interrupt 10 by executing the assembly instruction INT 10.
And finally, we stop our code execution by performing an infinite loop: with the statement JMP $ we are just jumping to the current address over and over again. You are utilizing with that statement one CPU core at 100%! With the assembly instruction TIMES 510 we tell our assembler to write zeros up to the position 510. And finally, we put the hex values 0xAA55 at the positions 511 and 512. We signal the BIOS that this code is a valid boot sector.
How do you assemble now this code? You need an assembler. For my whole OS development work, I have always used NASM. You can find in my GitHub repository a Docker file, which builds the whole necessary build environment for the OS inside a Docker Container. It installs NASM, and cross compiles the GCC C Compiler for the x32 and x64 processor architectures. We will talk later what a Cross Compiler is and why you need it. You can build that Docker Image by executing the following command from the command line:
docker image build -t sqlpassion/kaos-buildenv:latest .
But be aware: this will take a lot of time! On my AMD Ryzen system it took around 45 minutes, and on my Mac Book Pro 13” (year 2019) it took around 90 minutes! So, you can grab a coffee (or two?) in the meantime… To make everything as easy as possible, the folder with the boot sector also contains a makefile which builds the boot loader:
# Builds the final floppy image from which the OS can be booted kaosimg : bootsector.bin fat_imgen -c -s bootsector.bin -f kaos.img # Builds the boot sector bootsector.bin: $(ASM) nasm -fbin $(ASM) -o bootsector.bin # Clean up clean: rm -f bootsector.bin rm -f kaos.img
As you can see, I’m calling here NASM to assemble the boot code, where the actual file name comes from a parameter. The output of NASM is just 512 bytes binary code – our boot sector. The following image shows our boot sector – the highlighted section.
This boot sector must be written now into the first sector of a boot device. In my case I’m using floppy drives as a boot device, because they are much easier to program than HDD drives. Maybe we will change this at a later stage…
With the tool fat_imgen (which is also part of the customized Docker Image), you can create a bootable floppy disk file, which you can mount into your Hypervisor. The output of the makefile is finally the file kaos.img, which is a traditional 1.44 MB floppy image where the first sector contains our boot loader code. Let’s build now our first boot loader by executing the following Docker command:
docker run --rm -it -v $HOME/Documents/GitHub/SQLpassion/osdev:/src sqlpassion/kaos-buildenv /bin/sh /src/tutorials/001-bootsectors-barebones/build.sh 01_bootsector.asm
As you can see, I’m passing here in a file name (01_bootsector.asm), which is handed over to the makefile and to NASM. In addition, the source code folder (of the cloned GitHub repository) is mounted as /src into the Docker Container. That makes it possible to build the whole OS within the Docker Container. When the build process is finished, the Docker Container is terminated and removed automatically (through the –rm option). You can build with this command line different boot sectors. Just substitute the file name accordingly. If you want to clean up everything from the build process, you can also execute the script clean.sh:
docker run --rm -it -v $HOME/Documents/GitHub/SQLpassion/osdev:/src sqlpassion/kaos-buildenv /bin/sh /src/tutorials/001-bootsectors-barebones/clean.sh
When the build of the boot loader is finished, you create a new virtual machine (with your preferred Hypervisor) and mount the floppy image kaos.img accordingly. You can even delete the virtual hard disk because it is not needed.
Let’s start our virtual machine with our custom written boot loader:
Functions in Assembler
In addition to this simple boot loader, the GitHub repository also contains a more complex boot loader, where I have implemented a PrintLine function in assembler – to be able to print out strings in a more efficient way. Let’s have a look at this code:
; Tell the Assembler that we are loaded at offset 0x7C00 [ORG 0x7C00] [BITS 16] ; Setup the DS and ES register XOR AX, AX MOV DS, AX MOV ES, AX ; Prepare the stack ; Otherwise we can't call a function... MOV AX, 0x7000 MOV SS, AX MOV BP, 0x8000 MOV SP, BP ; Print out the 1st string MOV SI, WelcomeMessage1 CALL PrintLine ; Print out the 2nd string MOV SI, WelcomeMessage2 CALL PrintLine JMP $ ; Jump to current address = infinite loop ;================================================ ; This function prints a whole string, where the ; input string is stored in the register "SI" ;================================================ PrintLine: ; Set the TTY mode MOV AH, 0xE INT 10 MOV AL, [SI] CMP AL, 0 JE End_PrintLine INT 0x10 INC SI JMP PrintLine End_PrintLine: RET ; OxA: new line ; 0xD: carriage return ; 0x0: null terminated string WelcomeMessage1: DB 'Hello World from a barebone bootsector!', 0xD, 0xA, 0x0 WelcomeMessage2: DB 'I hope you enjoy this tutorial...', 0xD, 0xA, 0x0 ; Padding and magic number TIMES 510 - ($-$) DB 0 DW 0xaa55
First, you see at the beginning 2 so-called Directives that we are handing over to NASM:
With the ORG directive we are telling NASM that our boot loader code is loaded into memory at offset 0x7C00. This is necessary, because at the end of the file we have declared 2 string variables: WelcomeMessage1 and WelcomeMessage2. Without the ORG directive, NASM would calculate the wrong memory locations for these strings, because NASM assumes that everything is starting by default at memory location 0x0. And with the 2nd directive BITS we are telling NASM that we are dealing here with 16 bits assembly code.
Because you are calling functions, you also must set up an execution environment: we need a Stack. The stack is used to store temporary data when you perform function calls, like return addresses or input parameter values. The stack is also stored in main memory – at a given memory location. Which memory location you choose is up to you – it’s your OS, so you have to decide which information you put where in main memory. Over the time we will also develop here a whole Memory Map. In this boot loader, the stack is starting at the address 0x8000.
Caution: the stack ALWAYS grows downwards! When it starts at 0x8000, it can be only 512 bytes large (0x8000 to 0x7E00), because otherwise you would overwrite your boot loader code that was loaded to the memory region 0x7C00 to 0x7E00! As you can see, OS development can be quite dangerous, because you have the absolute control over your computer!
Everything else should be quite simple in this assembly file, when you know the basics of Assembler.
Implementing your own OS is a time challenging, but awarding process, because you learn so much about the inner workings of CPUs, and how modern operating systems are working. I have spent quite a lot of time developing my own x64 based OS and have learned here a lot – especially in computer science and system programming. Today we have talked about the basics – how a computer boots – and how to write your own boot loader code that can be executed. You can find the whole source code in my GitHub repository. In the next blog posting we will talk more about the FAT12 file format specification, and how to load a Second Stage Boot Loader from a FAT12 partition. Stay tuned.
Thanks for your time,