Writing a Bootloader from Scratch in FASM
Although OSDev and many other sites recommend first starting with GRUB, I wanted to write my own as a learning experience and see how far I can get with it before I needed to switch to GRUB. Anyone who has written a bootloader before knows that they are typically written in 16-bit assembly and must fit into a 512 byte package.
The bootloader is loaded into 0x7C00 and so I'll need to write code to offset the assembly code by that address. I also made the decision to load the kernel into address 0x1000, but this will likely change in the future. The last step is to buffer out the bootloader to 512 bytes and make the last 2 bytes 0xAA55. The FASM equivalent code for this is below:
; Instruct FASM to use 16-bit
use16
; Set global memory offset
org 0x7c00
; Memory offset where we'll load the kernel
KERNEL_OFFSET equ 0x1000
; Buffer bootsector out to 512 bytes
times 510-($-$$) db 0
dw 0xaa55
Adding in Print Functions
Writing functions such as the print function is fairly straight forward. My implementation takes the address of the string in register SI and simply loops until it hits the null character 0. I've also wrote a print function that prints out both words and bytes as hexadecimal.
; Put the address of the string in si prior to calling. Terminated by 0x00
print_string:
pusha
mov ah, 0x0E
.p_loop:
lodsb
cmp al, 0x00
je .done
int 10h
jmp .p_loop
.done:
popa
ret
; Assume the byte is in bx prior to calling
print_hex_word:
push ax
mov al, bh
call print_hex_byte
mov al, bl
call print_hex_byte
pop ax
ret
; Assume the byte is already in al
print_hex_byte:
pusha
; Get the higher 4 bits
mov dl, al
shr al, $4
call print_hex
; Get the lower 4 bits
mov al, dl
mov cl, 0x0F
and al, cl
call print_hex
popa
ret
; Assume the byte is already in al
print_hex:
pusha
mov ah, 0x0E
add al, 0x30
cmp al, 0x39
jbe .done
add al, 0x07
.done:
int 10h
popa
ret
Putting this all into a file called printfuncs.asm made it easy to include in main boot script. Again, calling our functions is as simple as moving our string address into register SI.
; Print name of Bootloader
mov si, startup_message
call print_string
; Print out stack location
mov bx, bp
call print_hex_word
; Loop forever here.
jmp $
; String Labels
startup_message:
db 'KoiZ OS. Stack Start: ', 0x00
Compiling the bootloader
The following code will compile the FASM code into a straight binary file we can write to a disk, or run directly in QEMU.
fasm.x64 bootsect.asm ../bin/bootsect.bin
Running it in QEMU is as simple as the following command:
qemu-system-x86_64 -fda bin/bootsect.bin
As expected, it printed out the stack address and than hung on the infinite JMP instruction.
Moving into 32-bit Protected Mode
Right now, everything is running in 16 bit. To take advantage of the 32-bit registers and memory, I'll need to switch the processor to 32-bit protected mode. To do this, I simply load the global descriptor table and set the control register to go into protected mode. Again, for FASM, this looks like this.
use16
use_32bpm:
; Disable interrups
cli
; Load our descriptor
lgdt [gdt_descriptor]
; Set the first bit of cr0 to goto protected mode.
mov eax , cr0
or eax , 0x1
mov cr0 , eax
; Execute farjump to the 32 bit code
; This apparently flushes the cpu pipeline of pre-fetched 16bit instructions
jmp CODE_SEG:init_32bpm
use32
init_32bpm:
; Remember that our old segments are useless now
mov ax, DATA_SEG
; Point segment registers to what we defined in the GDT
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
; Update stack position so its on top of free space
mov ebp, 0x90000
mov esp, ebp
call main_32b
The GDT definition I'm using is here. I documented it as best as I could, but a more in depth tutorial exists over at the OSDev Wiki.
; GDT
; Alright, time for some fun
gdt_start:
; This is required
; 32 bits
gdt_null:
dd 0x0
dd 0x0
; Code segment descriptor
; base = 0x0
; max = 0xFFFFF
gdt_code:
; Limit (bits 0-15)
dw 0xFFFF
; Base (bits 0-15)
dw 0x0
; Base (bits 16-23)
db 0x0
; Access Byte
; Present = 1b. Set to 1 for valid selectors.
; Priviledge = 00b. Ring 0 thru 3.
; Descriptor Type = 1b. Should be set for code/data segments and cleared for system segments
; Executable bit = 1b. Is 1 if code in the segment can be executed
; Direction bit = 0b. Code in this segment can be executed from an equal or lower privledge
; RW bit = 1b. Whether read access is allowed for code or write access is allowed for data.
; Accessed bit = 0b. CPU sets to one when the segment is accessed.
db 10011010b
; Flags and limit
; Granularity bit = 1b. 0 means byte granul, 1 means limit is 4 KiB blocks (page granul)
; Size bit = 1b. 0 means 16bit protected mode, 1 means 32 bit protected mode.
; Empty bits = 00b. Padding
; Limit bits = 1111b.
db 11001111b
; Base
db 0x0
gdt_data:
; Limit (bits 0-15)
dw 0xFFFF
; Base (bits 0-15)
dw 0x0
; Base (bits 16-23)
db 0x0
; Access Byte
; Present = 1b. Set to 1 for valid selectors.
; Priviledge = 00b. Ring 0 thru 3.
; Descriptor Type = 1b. Should be set for code/data segments and cleared for system segments
; Executable bit = 0b. Is 1 if code in the segment can be executed
; Direction bit = 0b. Code in this segment can be executed from an equal or lower privledge
; RW bit = 1b. Whether read access is allowed for code or write access is allowed for data.
; Accessed bit = 0b. CPU sets to one when the segment is accessed.
db 10010010b
; Flags and limit
; Granularity bit = 1b. 0 means byte granul, 1 means limit is 4 KiB blocks (page granul)
; Size bit = 1b. 0 means 16bit protected mode, 1 means 32 bit protected mode.
; Empty bits = 00b. Padding
; Limit bits = 1111b.
db 11001111b
; Base
db 0x0
; Label at the end helps the assembler calculate the size of the GDT for the GDT descriptor
gdt_end:
; Our descriptor contains
; - the size of our GDT (1 less than actual size)
; - start address of GDT
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
; Constants to help us later if needed
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
The main_32b function isn't listed, but it simply prints a string that says "Entered 32-bit protected mode" to console. Running
Writing the First Koiz OS Kernel
I wrote a diskload function to load the kernel at the KERNEL_OFFSET constant and a main function that's called by the function above.
load_kernel:
; Load 15 sectors from 0x0000(ES):KERNEL_OFFSET(BX)
mov bx, KERNEL_OFFSET
mov dh, 15
mov dl, [boot_drive]
call disk_load
use32
main_32b:
; Alright, enter into our kernel!
call KERNEL_OFFSET
jmp $
The kernel itself is written in C and doesn't do much but place an X in screen. Note that 0xB8000 is the video memory in protected mode so by writing in there, we can write characters directly to the screen. More information about that is available over at OSDev.
void main () {
char * video_memory = ( char *) 0xb8000 ;
*video_memory = 'X';
}
The last thing to do is write a quick kernel entry function which is placed at the beginning of the to call our main() function from C.
format ELF
use32
section '.text' executable
extrn main
call main
jmp $
Putting it All Together
The most important thing for us to worry about at this point is making sure that kernel_entry shows up first!
# Make sure these folders exist
mkdir -p ../bin
mkdir -p ../obj
echo "compiling 32-bit kernel entry code"
# Build our custom kernel entry executable
fasm.x64 kernel_entry.asm ../obj/kernel_entry.o
echo "compiling 32-bit kernel"
# Compile the kernel
gcc -m32 -ffreestanding -c main.c -o ../obj/main.o -fno-pie
echo "linking 32-bit kernel"
# Link everything together
ld -o ../bin/kernel.bin -Ttext 0x1000 ../obj/kernel_entry.o ../obj/main.o --oformat binary -m elf_i386
Adding the kernel image to the bootloader is as simple as combining them with the cat command.
cat bin/bootsect.bin bin/kernel.bin > bin/koizos-img.bin
After running our new image in QEMU, one can see that the kernel loaded and replaced the E with an X!
Conclusion
The full code up to this point is available here: https://github.com/drakeor/koiz-os/tree/9bc105183c74e814e4feb1a988c8cd771479b111
As we don't have a standard library or any functions like that in general, the next steps are to develop our own standard C library for use with our OS!