Until now, every time we made any modification to the bootloader or our OS, we had to manually recompile and link the files. Throughout history, previous programmers have faced this situation, so they created a tool called make. With make, we can indicate dependencies between files and commands to execute in case any of them have been modified.
Before we start, it is recommended that you read these previous articles:
- Boot Sector
- Interrupts
- Memory
- Stack
- IF-ELSE
- Functions
- Memory Segmentation
- Reading Data from Disk
- Entering Protected Mode 32bits
- Compilation, Linking, Stack Management, and Variables in C
- Pointers
- Kernel
- EntryPoint
As we are going to use GNU make, we will have to install gmake:
A basic example could be this:
kernel.o : kernel.c
cc -m32 -ffreestanding -c kernel.c -o kernel.o
NOTE: Be careful with spaces and tabulations because Make requires that they are real tabulations.
We can compile the kernel with the command:
cc -m32 -ffreestanding -c kernel.c -o kernel.o
The beauty of Make is that it will only execute the command if any of the dependencies have been modified. If we run make again, it will indicate that we already have the latest version and there is no need to recompile:
gmake: 'kernel.o' is up to date.
A more advanced Makefile could be this one, when running make kernel.bin, if any of the dependencies need to be recompiled, it will do so automatically:
# Build the kernel binary
kernel.bin : kernel_entry.o kernel.o
ld -o kernel.bin -Ttext 0x1000 kernel_entry.o kernel.o --oformat binary
# Build the kernel object file
kernel.o : kernel.c
cc -m32 -ffreestanding -c kernel.c -o kernel.o
# Build the kernel entry object file .
kernel_entry.o : kernel_entry.asm
nasm kernel_entry.asm -f elf -o kernel_entry.o
Make supports several special variables, making script maintenance easier as the project grows:
- $^: Indicates all dependencies
- $<: Indicates the first dependency
- $@: Indicates the file to be generated
Below is the final version of my Makefile:
# Run Qemu to simulate booting of our code .
run : all
qemu-system-x86_64 os-image
# Compile all elements
all : os-image
# This is the actual disk image that the computer loads ,
# which is the combination of our compiled bootsector and kernel
os-image : boot_sect.bin kernel.bin
cat $^ > os-image
# This builds the binary of our kernel from two object files :
# - the kernel_entry , which jumps to main () in our kernel
# - the compiled C kernel
kernel.bin : kernel_entry.o kernel.o
ld -o kernel.bin -Ttext 0x1000 $^ --oformat binary
# Build our kernel object file .
kernel.o : kernel.c
cc -m32 -ffreestanding -c $< -o $@
# Build our kernel entry object file .
kernel_entry.o : kernel_entry.asm
nasm $< -f elf -o $@
# Assemble the boot sector to raw machine code
# The -I options tells nasm where to find our useful assembly
# routines that we include in boot_sect . asm
boot_sect.bin : boot_sect.asm
nasm -f bin $< -o $@
# Clear away all generated files .
clean :
rm -fr *.bin *.dis *.o os-image *.map
# Disassemble our kernel - might be useful for debugging .
kernel dis : kernel.bin
ndisasm -b 32 $< > $@
We clean up and check that the whole process works:
rm -fr *.bin *.dis *.o os-image *.map
We compile everything necessary, generate the disk image, and load it into Qemu, all in one command.
Excellent, everything should have worked smoothly and we will have the latest version of our bootloader/kernel running in Qemu. Whenever we want to modify something, we just need to run make run and our script will take care of recompiling everything necessary.