A Multiboot-compliant Flat Image
Like most young, eager and rather clueless programmers who tried writing an OS way before they were even remotely prepared for something like that at some point in the late 90s or so, I, too, stumbled upon the place where most amateur OS projects die — writing the bloody bootloader. Things are a lot easier now on platforms that have a multiboot-compliant bootloader, like Grub.
The somewhat unpleasant part is that everyone insists on shoving ELF down your throat along with Multiboot. Which is nice and ELF is a useful and surprisingly decent executable format, but what’s the fun in that? Today, we’re going to boot a flat (“kludgy”, as Multiboot describes it) image.
Bootloaders conforming to the Multiboot specifications can parse the ELF header of a binary to figure out where to load it and where to start executing things from. However, building an ELF image also brings into play a lot of additional tooling: you need a linker and a linker script to produce it, you need an ELF-aware tool like objdump to analyze it and so on.
This isn’t one of my rants about complexity though: ELF really is OK. Like anything that attempts to do things as complex as specifying a portable executable format, it has its rough edges, but it’s a good thing overall. However, if you’re just starting out to play with bare-metal programming (especially on an architecture like x86, _64 or not), ditching out as much complexity as possible helps.
So we’re going to build a minimal boot image without the ELF baggage: it’s going to have only the valid header and a very small entry function (that just spins a loop and sets a register to something recognizable so that we know it’s there). Instead of relying on information in the ELF header, we— which the kernel image constructs to inform the bootloader about things like where to load it and where its entry point is — and a set of rules that define the state in which the CPU is when the bootloader relinquishes control of the system.
Historically, each OS did pretty much what it wanted in this area, and therefore had to come with their own bootloaders; “universal” bootloaders like GRUB had to know how to handle each OS’ kernel (which was often too complicated anyway, hence tricks like chainloading). Writing a bootloader for old x86 systems is gruesome because of the limitations; writing a bootloader for new x86 systems is gruesome because of the legacy. No one who values their mental sanity wants to do it. People eventually figured out to write a thing that loads a kernel once, and make the kernel conform to a specific interface so that they could stop inventing shit like x86 bootloaders.
The Multiboot Header
The Multiboot header is a 48-byte sequence of data that has to be somewhere in the first 8KB of a kernel’s image. It has 12 fields, the last 4 of which are of no concern to us today because they deal with video; the other eight — which are of interest to us — are:
- magic: this is always 0x1BADB002, and is a magic number based on which the bootloader can find the Multiboot header.
flags: a bitmapped array of flags about what the bootloader should or shouldn’t do with your kernel. We’re particularly interested in three of them:
- Bit 0 should be set if you want the bootloader to align any boot modules to page boundaries. “Boot module” is Multiboot slang for a file that the bootloader can load into memory at some location that it will pass on to the OS.
- Bit 1 should be set if you want the bootloader to pass memory information (i.e. minimum and maximum addresses) to the OS
- Bit 16 should be set in order to inform the bootloader that your image is not an ELF executable. This means that it won’t have an ELF header to get information about where to load symbols from, and that it should instead use the information that you have manually inserted in the header (we’ll see how and where in a minute) for that.
Checksum: this is a 32-bit value which, added to the magic value in the first header field, must yield 0.
- Header address: the physical address at which the multiboot header in the image is loaded.
- Load address: the address at which the image itself is loaded
- Load end address: the physical address of the last byte of the image (i.e. how much memory to load. If left to 0, the bootloader will just assume that the text and data sections occupy the whole image.
- BSS end address: the physical address of the end of the BSS section. This can be used by the bootloader to initialize that area to 0.
- Entry address: the physical address at which to jump once the image is loaded (i.e. the address of your entry point).
4 and 5 together synchronize the beginning of the image and the header offset. In general, you’re free to put the multiboot image anywhere in the first 8K of the image (although it seems to make a lot of sense to just stick it in the beginning, as we’ll do below). But you do have the option of putting it somewhere else.
So, let’s write a multiboot header that describes a flat image which:
- Will be present at the beginning of the image (i.e. header_addr = 0x100000)
- Will be loaded from 0x100000 (i.e. load_addr = 0x100000)
Picking the start of the entry function is a little trickier because it requires us to be aware of a particular detail of Multiboot loading: it leaves ESP in an undefined state and we have to create our own stack space.
For simplicity, we’re going to place the multiboot header in its own 4K (0x1000) page. We’ll setup a 16K (0x4000) stack right after it, and we’ll put the beginning of the text section after that, so 0x1000 + 0x4000 = 0x5000 bytes after the beginning of the image. This means that
- The entry function (which I will christen _start) will be at 0x105000 if the image is loaded from 0x100000
OK, let’s write it up!
bits 32 ; Magic number for Multiboot loaders MBOOTMAGIC equ 1BADB002h ; Flags: align all boot modules on page boundaries, ; pass memory information to the OS ; don't parse an ELF header, use the multiboot header ; to figure out where to load kernel ; MB_PAGE_ALIGN equ 1<<0 MB_MEMINFO equ 1<<1 MB_KLUDGE equ 1<<16 MBOOTFLAGS equ MB_PAGE_ALIGN | MB_MEMINFO | MB_KLUDGE MBOOTCHECKSUM equ -(MBOOTMAGIC + MBOOTFLAGS) ; Multiboot header: loaded at 0x HDR_HEADER_ADDR equ 100000h ; Kernel loaded starting from 0x5000: there's a 0x4000-byte ; stack region, and one 4K page at the beginning for the ; header. HDR_LOAD_ADDR equ 100000h HDR_ENTRY_ADDR equ 105000h ; Multiboot header information SECTION multiboot align 0x4 dd MBOOTMAGIC ; Offset: 0 dd MBOOTFLAGS ; Offset: 4 dd MBOOTCHECKSUM ; Offset: 8 dd HDR_HEADER_ADDR ; Offset: 12 dd HDR_LOAD_ADDR ; Offset: 16 dd 0 ; Offset: 20 dd 0 ; Offset: 24  dd HDR_ENTRY_ADDR ; _start is 0x5000 after load address times (4096 - 32) db 0 ; 0 up to 4K ; Note 1: The bootloader will assume no BSS section is given. ; A 16 KB temporary stack. The Multiboot standard leaves ESP ; undefined by default, and allocates no space for the stack, ; so it's up to us to do that. SECTION bootstrap_stack stack_bottom: times 16384 db 0 stack_top:
Do note that, since we’re making a flat image, section names are largely decorative. There is also no reason why the header has to be in its own section; it’s just easier to explain this way, and the organization easier to visualize. This is not optimum.
As for the entry function, it can be as trivial as:
SECTION text _start: ; Initialize ESP mov esp, stack_top mov eax, 0 jmp _start
As long as it produces a reasonable result, it’s enough.
Generating the binary file
Ok, let’s compile the whole thing and generate a bootable ISO image.
You’re going to need Grub’s stage2_eltorito file in order to create a bootable image (or grub itself, I guess, but all my development machines run OpenBSD so I don’t have grub around here…). You can find it floating around the web in a lot of places. Here it is.
We’ll start by creating a directory to generate the ISO image from.
$ mkdir -p iso/boot/grub
stage2_eltorito should reside in iso/boot/grub. Now let’s compile the kernel:
$ nasm ./boot.S && cp ./boot ./iso/kernel.bin
(Or use nasm’s -o flag.)
Let’s make the image:
$ mkisofs -R -b boot/grub/stage2_eltorito -no-emul-boot -boot-load-size 4 -boot-info-table -o grub.iso iso
I know, it’s long. Don’t blindly copy-paste it. Read man mkisofs first. Go ahead, I’ll wait.
Now fire up qemu and let’s bask in the beauty of our creation
$ qemu-system-x86_64 -cdrom ./grub.iso -m 32 -s -S
Fire up a GDB session and connect to the QEMU instance:
> target remote localhost:1234 > c
Tell Grub where our kernel is and ask it to boot it for us:
grub> kernel /kernel.bin grub> boot
Now interrupt the remote session in GDB and inspect the registers.
Fun things to do now:
- Leave the _start entry empty (just a jmp to _start). What’s the value of EAX? Can you think how that might be useful?
- Inspect the memory information passed by the multiboot kernel.
- Write a program that examines the flat image you just wrote and prints out information about it (i.e. the elements of the header in readable form).