How to make a bootloader
Disclaimer
Part 1 - Make it boot!
Before we even learn how computers work, what a bootloader is, or what assembly looks like, we are going to make something boot on a computer!
Step 1 - Make a boot sector:
First, switch to wherever you are doing the development in. I recommend a git repo, but any directory should be fine!
Make a file, boot.asm, and inside it include the following, word for word:
[org 0x7c00]
mov ah, 0x0e
mov al 'H'
int 0x10
jmp $
times 510-($-$$) db 0
dw 0xaa55This is short, but quite confusing and intimidating. Please don't worry about not understanding it, it is quite quick to learn!
Step 2 - Build it:
We have our code now, but we need to get it into a format that can be run on a computer.
First, run:
nasm -f bin boot.asm -o boot.binThis will build our boot sector. That's it!
Now we can run it with
qemu-system-x86_64 -drive format=raw,file=boot.binIf everything goes well, we should see a black screen, with the letter H sitting alone.
Congrats! You just told a CPU to do something directly, right after booting up!
This is OS development.
Step 3 - Woah, Woah, WOAH. What did we just make?
Now that we have our running example, lets talk about what just happened.
[org 0x7c00]
org tells the assembler what place in memory to place the code.
When the computer starts, it loads the boot sector at 0x7c00 (the reason for this particular number is complicated), and through telling the compiler that the offset is 0x7c00, any addresses we use will be offset by 0x7c00.
mov ah, 0x0e, mov al, 'H'
To understand this, we need a little explanation of what mov is first:
The way I like to think of mov is "fill this location with this", so in this case 0x0e is getting copied to the ah register, and H is getting copied to the al register.
Registers are fast storage locations in the CPU. They get used constantly in ASM. Think of them like the variables you are used to in other languages. The cool thing about these registers is they often have special uses, so the BIOS can check them to dictate how it behaves.
Going back to the example here, (mov ah, 0x0e), we are using the ah register.
The ah (and al) registers are part of the ax register. They both hold 8 bit values (al is the upper 8 bits, ah the lower), and together create a 16 bit register, ax. In this case, ah holds the bios function id (common ones here), so when we call the interrupt 0x10, (we'll get to that in a second) the CPU knows to display whatever is in the al register to the screen (in this case H)!
This means that the ax register looks something like this: 0x0E48
As you can see, ah (aka the first 8 bits of ax) were set to 0E, and the second 8 bits were set to 48, corresponding to H.
int 0x10
This is our first interrupt, and this code in particular, 0x10, is usually associated with displaying things to the screen. Most resources call this interrupt the video interrupt, so I will be using this term going forward. Nothing can explain the idea of interrupts as a whole and this 0x10 interrupt as well as https://wiki.osdev.org/BIOS#INT_0x10 , so I highly recommend reading that now. As a whole, the osdev wiki is an amazing resource, and I'd definitely look there first for anything.
(I should mention, AI tools are abysmal at osdev. Such a big part of osdev is planning and writing good, streamlined code, which AI is horrible at. Please, please do not heavily rely on AI)
When an interrupt is called, the CPU generally looks at the ah register to figure out what to do, so calling int 0x10 while ah is 0x0e tells it to display a character.
Basically, the flow for most interrupts will be something like:
- read
ahto determine what action the programmer is calling - read relevant registers to source data to perform said task
- perform actions (in this case, printing a character to the screen)
As you can see, assembly has a pretty steep learning code. I'd say osdev as a whole is probably the most documentation intensive application of programming, but don't worry, most of the time non-bootloader code will be written in a higher level language, like C or Rust, and we can abstract the code away from having to manage all of these registers and doing all of the interrupts and things directly.
jmp $
This is a fairly simple instruction, as $ just means the current instruction address, so it is telling it to continuously jump to the current address, which runs jmp $ again, infinitely spinning. This way, you have time to read the character we print, and it keeps the CPU busy so it doesn't keep moving down the memory looking for stuff to read, even when none exists.
times 510-($-$$) db 0
dw 0xaa55These instructions are quite complicated, and to understand them we must first understand how the CPU determines a proper boot sector. In order to boot, the boot sector must meet 2 criteria:
-
it must be 512 bytes in size This is why we pad the bootloader with 0s after the code. We do this through
(times 510-($-$$) db 0, but this is super complicated, so let me break it down real quick. I think it is pretty self explanatory thattimes n db 0would fill n bytes with 0s, and$is the current position, and$$is the start of the current section. This means that we end up padding the rest of the file with zeros, leaving two spaces for the next requirement) -
it must end in 55 AA
dw 0xaa55this writes the signature in little endian, which just flips it, adding55 AAto the end of the file.
That's it! I know it is still pretty confusing, but if we were to run this on a live pc, we would be directly telling the cpu what to do! Of course, this 512 byte limit is very strict, so now we will improve it!
Part 2 - Loading more than 512 bytes
Currently, we only have the space for one boot sector, and were we to try and do anything we would find that this limit gets tight very quickly. Real bootloaders load code from a disk, and we are going to take the first step towards this goal by printing some more text, and then loading the next sector from the desk.
(Advanced note: we will stay in 16 bit real mode for now)
Lets create a new asm file, stage2.asm. Inside it, lets write some more code:
[org 0x7e00]
mov ah, 0x0e
mov al, 'S'
int 0x10
mov al, '2'
int 0x10
jmp $This has a good bit in common with our previous example, and I bet with a little bit of thought, we can figure out what this does! The main thing different in this example is the origin, 0x7e00. If you remember, the BIOS loads the boot sector at 0x7c00, so we choose to put the code at 0x7e00 so they don't overlap.
Now, lets build our stage 2. This command is pretty much the same as the first one, just with different file names:
nasm -f bin stage2.asm -o stage2.binNow we have a "payload" of sorts to run, but we need to figure out how to load it into memory in the first place. Thankfully, the BIOS disk interrupt makes this quite easy, if not a little boilerplate-heavy.
Now, return to your boot.asm, and replace the current code with this:
[org 0x7c00]
mov ah, 0x0e
mov al, 'B'
int 0x10
mov bx, 0x7e00
mov ah, 0x02
mov al, 1
mov ch, 0
mov cl, 2
mov dh, 0
mov dl, 0x80
int 0x13
jc disk_error
jmp 0x0000:0x7e00
disk_error:
mov ah, 0x0e
mov al, 'E'
int 0x10
jmp $
times 510-($-$$) db 0
dw 0xaa55Quite frankly, there is a lot to cover here, and I think the best way is to work from what we already know. We first can recognize a few parts here from the previous examples:
- We set the offset to
0x7c00 - We print the letter
B - We move some stuff and call an interrupt (
0x13) - We jump
- We pad the file with zeros and end with the signature
55 AA.
While I know this example is a lot to look at, and likely your second introduction into assembly or at least writing bootloaders, this presents a very valuable opportunity to learn the process of, well, learning! Going back to the previous example, when I explained interrupts, I highly recommended that you look at the osdev wiki page for the 0x13 interrupt. I know it will require thought, and probably be a little annoying, but a very good exercise is using the wikipedia, or my personal resource of choice, Ralf Brown's interrupt list, which is more difficult to read, but just so, so valuable. This is just a one shop stop for anything interrupt related.
Now, use either of these to figure out what each mov instruction does here. Please, please don't continue until you do. I will explain it, but not in much depth, and the experience of looking things up is very important.
First, lets look at the code for loading the "payload" into memory. It starts out by moving the memory offset pointer to the memory address 0x7e00. If we use the aforementioned resources, we can see that the buffer address pointer format is ES:BX. We want to edit the second part of the address, so we put the location of the second sector (aka BX) to be 0x7e00.
Input
| Register | Value | Meaning |
|---|---|---|
AH | 0x02 | Which action do we take when an interrupt is called? |
AL | 1 | How many sectors (512 bytes) do we want to read? |
CH | 0 | The lower 8 bits of the cylinder number (don't worry about this for now) |
CL | 2 | The bits at 0-5 (basically 1-63) makes up the sector number, and the higher 2 bits make up the high 2 bits of the cylinder number (for hard drives, works with CH) |
DH | 0 | This is the head number. (tbh don't know what this is, I just think of it as another data partition like cylinders) |
| DL | 0x80 | This is the drive number. 0x00-0x7f is for floppy drives, and 0x80-... is for hard drives, so 0x80 is the first one. |
| ES:BX | 0x0000:0x7e00 | This is the location to place the data in memory. The offset (BX) is the important one for now. |
Next, if we look at the above function table, we see that ah, like in the printing example, dictates what the intercept does. We want to read from the drive, so we use AH = 0x02 to set the "mode". Then, if we scroll down we see that the operation of calling int 0x13 when AH is 0x02 is Read Sectors From Drive. This page includes the parameters and results from this function, so we can determine that we need to set al, or the sectors to read count to 1, the cylinder (CH) to 0, the sector number (CL) to 2, the head (DH) to 0, and the drive (DL) to 0x80.
Then, finally, once we've finally set all of this, we can call the intercept 0x13 with int 0x13.
Now, we run into something new: jc. Assembly, unfortunately, doesn't use the if - else structure. Instead, compare operations and some intercepts (including 0x13) use flags based on the result of said operations, and then we use conditional jumps, like jc to act upon the results. In this case, jc looks at CF, the carry flag, which as per the output section of the function table above, is set to true on error, and false if no error. In this case, if CF is true, we jump to the disk_error label, and run from there, printing the letter E and looping infinitely. If CF is false, though, (meaning that we moved the code without error) we continue down the code to an unconditional jump, which jumps to where we have loaded code to memory.
Now that we (hopefully, I'm sorry if I didn't explain it well enough!) understand the code, we need to compile it so that they are linked. Doing this on Linux is actually quite easy! Simply recompile the boot.asm with
nasm -f bin boot.asm -o boot.binNow, we have the two binaries, boot.bin, or the entry point, and stage2.bin which is the "payload" or the second part of the flow for this binary.
There are a few ways we can do this, but for now lets just create a little ten sector drive with:
dd if=/dev/zero of=disk.img bs=512 count=10This makes a "drive", I'm sure you've encountered this file, especially if you use Linux, but right now it is just reserved space, ie: there is nothing in it! We need to place our code into the correct place on the "drive" we made. If we recall, the bootloader is at the location, 0x7c00 and the second part is at the location 0x7e00, or right after the bootloader. The correct way to arrange the drive, then would be to place the bootloader in the first 512 bytes, and then the payload right after that. We can do this through the dd command:
dd if=boot.bin of=disk.img conv=notrunc
dd if=stage2.bin of=disk.img bs=512 seek=1 conv=notruncNow, if we boot our "drive" with:
qemu-system-x86_64 -drive format=raw,file=disk.imgWe should see:
B
S2Currently, we have a "multistage" bootloader, and we can make the sector size bigger in the bootloader and mess around, making pretty much whatever we want. The next step is to start using C for the program and move away from the pain of assembly, and allow us to abstract, but that is beyond the scope of this guide. Hopefully you have learned quite about how the BIOS loads the OS now, and have the first step towards writing an OS from scratch!
Important Resources:
OSdev wiki Ralf Brown's intercept list Wikipedia
Most importantly, above all, don't be afraid to google and ask questions! In comparison to many programming spaces and help forums on the internet, osdev communities are very friendly and helpful, and as long as you have a genuine desire to learn (and don't use ai), you will be met with the smartest, most friendly people on the internet.
Thank you for reading, if you have any questions, clarifications, recommendations, or just wanna talk, shoot @jackson (meee) a dm on Slack!\