DOS from Scratch: Initial Bootloader
We can't run anything if we don't have a way to boot the machine, so step one is writing a bootloader. We don't have a kernel to load yet so for now we'll focus on just getting the machine to boot and print a character.
Essential Tools
Before we begin you'll need a few tools to start developing. At minimum you'll need an assembler and an x86 emulator. I'm developing on Linux so I'm going to be using NASM and QEMU respectively. If you're running macOS you should be able to install them using Homebrew, and if you're using Windows I recommend setting up the Windows Subsystem for Linux.
Let's Boot a Computer
To get started, here's the most minimal bootloader I can write that's guaranteed to run on just about any x86 based machine. Don't worry if you don't understand it just yet, I'm going to break it all down later.
; boot.asm
[bits 16]
[org 0x7c00]
main:
; disable interrupts
cli
; make sure the CPU is in a sane state
jmp 0x0000:clear_segment_registers
clear_segment_registers:
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, main
cld
; re-enable interrupts
sti
; print a dollar sign
mov al, "$"
mov ah, 0x0e
int 0x10
; "It's now safe to turn off your computer."
hlt
jmp $-1
; padding
times 510-($-$$) db 0
; literally magic
dw 0xaa55
You can build this by doing nasm -fbin boot.asm -o boot.img
, and then run it by doing qemu-system-x86_64 -drive format=raw,file=boot.img
. If everything worked correctly, the computer should have booted, printed a single $
to the screen, then halted.
The Boot Process
When you power on a computer, the first thing that happens is some firmware that lives in a ROM chip on the motherboard is loaded into memory and executed. In the case of IBM compatible PCs, that firmware is called BIOS.
Note: BIOS is not the generic term for firmware. It is a specific firmware that provides a specific set of functions for the operating system to make use of. So whenever I refer to BIOS I'm talking about that specific interface.
The way BIOS boots a computer is to read the first 512 bytes from some boot disk and load it into memory at the location 0x7c00
(that's 31744 in decimal). Astute observers will note that's exactly 1KB below 32KB. It then jumps to 0x7c00
and starts executing.
Note: the x86's memory is byte addressable counting from 0. As an example, that means the address 31 points to the 32nd byte of memory.
So what we've done is generate a 512 byte blob of machine code and told QEMU that blob is a hard drive. QEMU then takes that blob and reads the first 512 bytes of it (which is all of it) into memory and executes it.
What Does the Code Do?
Directives
There are two directives to the assembler at the beginning of the file.
[bits 16]
[org 0x7c00]
The first directive, bits 16
, tells the assembler to generate 16-bit code.
The second directive, org 0x7c00
tells the assembler where in memory the binary will eventually be loaded. This is necessary for the assembler to calculate the correct memory addresses of labels. As an example from this code, mov sp, main
needs to resolve to mov sp, 0x7c00
and not mov sp, 0x0000
.
Resetting the Segment Registers
The first actual instructions occur after the main
label. The lines beginning with cli
and ending with sti
are responsible for resetting the segment registers.
So far when I've talked about memory, it's been using a single 16-bit number (like 0x7c00
). However, using a single 16-bit number you can only count as high as 65,535, which is far less memory than an x86 can access. The way x86 CPUs actually access memory is by using a pair of 16-bit numbers called the segment
and offset
. Memory is divided into 64KB sized segments that are offset by 16 bytes. So segment 0 spans from 0 to 65,535, segment 2 spans from 16 to 65,551, and so on. The offset is the address within a segment. This means that there are multiple segment and offset combinations that can point to any given memory address.
To summarize, you can calculate a memory address as segment * 16 + offset
. Absolute memory addresses are often written as segment:offset
. Following that convention, the boot sector is loaded at 0x0000:0x7c00
.
So how do we actually use segment registers? The x86 has four segment registers: cs
(code segment), ds
(data segment), ss
(stack segment), and es
(extra segment). Whenever you issue an instruction that accesses memory, the value corresponding register is used. For example:
jmp 0x0001
jumps tocs:0x0001
mov ax, [0x0001]
loads the value atds:0001
intoax
push ax
saves ax toss:sp
and decrementssp
(the stack grows towards 0).es
can be used to specify other segments, such as doingmov ax, [es:0x001]
if you need to load memory not in the current segment.
It's possible for some (buggy) BIOS to have the segment registers setup incorrectly on boot. They should all be 0x0000
, but we reset them just to be safe. This process can seem a bit convoluted at first so I'll explain it in detail.
This section begins with cli
to disable interrupts and ends with sti
to re-enable them. This is a precaution that I'm not sure is actually necessary, but I've included it just to be safe.
Next is the instruction jmp 0x0000:clear_segment_registers
. It may seem a bit odd to jump to the next instruction, but this is how you change the code segment. This is because you need to update the code segment and instruction pointer (that is to say the code offset) at the same time to prevent jumping to an unintended memory location.
Next we set the ds
, es
, and ss
registers. These can be directly set, but not to an immediate value. So mov ds, 0
is not a valid instruction. instead we need to set some other register (in this case ax
) to 0, then move that value using mov ds, ax
.
Finally we set the stack pointer (sp
) to main
so the stack grows away from our code.
Printing “$”
The next 3 lines are the actual code for the bootloader, which prints “$”.
This works by triggering the interrupt 10h
to invoke a routine provided by the BIOS. The value in ah
indicates which function to call, which in this case corresponds to “teletype output”. The value that gets outputted is the character in al
, which we set to be $
.
Halting the CPU
The next two lines halt the CPU. hlt
executes the halt instruction which causes the CPU to hault and wait for an interrupt. Because interrupts periodically fire execution will eventually continue and we need to jump back to the halt instruction. $
is the current location so jmp $-1
jumps back 1 instruction.
Padding the Boot Sector and Magic Number
times 510-($-$$) db 0
pads the file with zeroes until it's 510 bytes long. Finally, dw 0xaa55
writes the two byte magic number that the BIOS requires a boot sector to end with, thus completing the 512 byte sector.