Recall Some Past
On an x86 machine, the BIOS selects a boot device and copies the first sector from the device into physical memory at address 0x7C00
. This first sector, known as the boot sector, is 512 bytes in size. It contains the boot loader code, a partition table, the disk signature, and a "magic number
" that the BIOS checks to ensure the sector is intended to be a boot sector. After this, the BIOS instructs the CPU to jump to the start of the boot loader code, thereby passing control to the boot loader.
Steps Bootloader to Kernel
In this chapter we will be only concerned about the boot loader code, which will start the hello world kernel. Because we can't fit whole thing into 512 bytes. In order to start our kernel, the our bootloader will have to perform the following tasks:
- Load the kernel from the disk into memory.
- Set up the Global Descriptor Table (GDT).
- Switch from 16-bit real mode to 32-bit protected mode and pass control to the loaded kernel.
1 Load the Kernel from Disk into Memory
The bootloader must read the kernel from the disk and load it into a specific memory location. This typically involves using BIOS interrupts to read from the disk.
2 Set Up the Global Descriptor Table (GDT)
The GDT defines the memory segments for code, data, and other purposes. Here's an example of setting up the GDT:
section .data
; Define GDT entries
gdt_data:
; Null segment descriptor
dd 0
dd 0
; Code segment descriptor
dw 0xFFFF
dw 0
db 0
db 10011010b ; 32-bit code segment, present, ring 0
db 11001111b ; Limit bits 16-19
db 0
; Data segment descriptor
dw 0xFFFF
dw 0
db 0
db 10010010b ; 32-bit data segment, present, ring 0
db 11001111b ; Limit bits 16-19
db 0
; GDT descriptor
gdt_descriptor:
dw $ - gdt_data - 1 ; Limit (size of GDT), 16 bytes (2 entries * 8 bytes per entry) - 1
dd gdt_data ; Base address of GDT
section .text
global load_gdt
load_gdt:
; Load GDT
lgdt [gdt_descriptor]
ret
3 Switch from 16-bit Real Mode to 32-bit Protected Mode
Enable the Protection Enable (PE) bit in the Control Register 0 (CR0), set up the segment registers, and perform a far jump to load the new code segment.
section .text
global _start
_start:
; Load the GDT
call load_gdt
; Enable protected mode (set PE bit in CR0)
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
; Set up data segment registers
mov ax, 0x10 ; Data segment selector (GDT index 1)
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
; Jump to the next instruction to ensure segment registers are loaded
jmp 0x08:protected_mode_entry
protected_mode_entry:
; Now in protected mode
; Your kernel entry point here
call load_idt ; Optionally load IDT if interrupts are needed
; Your kernel code here
Load the IDT (Optional)
If you need to handle the interrupts in protected mode, you will need to set up and load the Interrupt Descriptor Table (IDT).
section .data
; Define IDT entries
idt_data:
times 256 dd 0 ; 256 entries, each entry is 8 bytes long
; IDT descriptor
idt_descriptor:
dw $ - idt_data - 1 ; Limit (size of IDT), 256 entries * 8 bytes per entry - 1
dd idt_data ; Base address of IDT
section .text
global load_idt
load_idt:
; Load IDT
lidt [idt_descriptor]
ret
Writing the Boot Loader
MBR (Master Boot Record) File
The main assembly file for the boot loader contains the definition of the master boot record, as well as include statements for all relevant helper modules. Below is the whole code of this file and then discuss each section individually.
[BITS 16] ; Assemble for 16-bit mode
[org 0x7c00] ; The boot sector is loaded at address 0x7c00
KERNEL_OFFSET equ 0x1000 ; The same one we used when linking the kernel
mov [BOOT_DRIVE], dl ; Remember that the BIOS sets us the boot drive in 'dl' on boot
; set up the stack
mov bp, 0x9000
mov sp, bp
mov bx, MSG_16BIT_MODE
call print16
call print16_nl
call load_kernel ; read the kernel from disk
call switch_to_32bit ; disable interrupts, load GDT, etc. Finally jumps to 'BEGIN_PM'
jmp $ ; Never executed
%include "boot/print-16bit.asm"
%include "boot/print-32bit.asm"
%include "boot/disk.asm"
%include "boot/gdt.asm"
%include "boot/switch-to-32bit.asm"
[bits 16]
load_kernel:
mov bx, MSG_LOAD_KERNEL
call print16
call print16_nl
mov bx, KERNEL_OFFSET ; Read from disk and store in 0x1000
mov dh, 31
mov dl, [BOOT_DRIVE]
call disk_load
ret
[bits 32]
BEGIN_32BIT:
mov ebx, MSG_32BIT_MODE
call print32
call KERNEL_OFFSET ; Give control to the kernel
jmp $ ; Stay here when the kernel returns control to us (if ever)
BOOT_DRIVE db 0 ; It is a good idea to store it in memory because 'dl' may get overwritten
MSG_16BIT_MODE db "Started in 16-bit Real Mode", 0
MSG_32BIT_MODE db "Landed in 32-bit Protected Mode", 0
MSG_LOAD_KERNEL db "Loading kernel into memory", 0
; padding
times 510 - ($-$$) db 0
dw 0xaa55
Explanation:
When developing a bootloader, the first thing to notice is that we are going to switch between 16-bit real mode and 32-bit protected mode. Therefore, we need to instruct the assembler whether it should generate 16-bit or 32-bit instructions. This can be done by using the [bits 16]
and [bits 32]
directives, respectively. We start with 16-bit instructions since the BIOS jumps to the bootloader while the CPU is still in 16-bit mode.
- Setting up 16-bit mode:
[BITS 16]
directive tells the assembler to use 16-bit instructions.[bits 16]
:This directive tells the assembler to generate 16-bit code, which is necessary because the CPU starts in real mode (16-bit mode).[bits 32]
: After transitioning to protected mode, this directive is used to generate 32-bit code.
- Origin:
[org 0x7c00]
directive in assembly language specifies the starting offset for the code or data that follows. This directive informs the assembler about the memory address at which the code will be loaded.- The BIOS loads the first sector of the boot device (which is 512 bytes) into memory at the address
0x7C00
. Therefore, we use theORG 0x7C00
directive to ensure that all addresses and offsets in the bootloader code are calculated correctly. - This directive tells the assembler that the subsequent code should be assembled as if it starts at memory address
0c7c00
. This is important because the BIOS loads the boot sector to this address.
- The BIOS loads the first sector of the boot device (which is 512 bytes) into memory at the address
KERNEL_OFFSET equ 0x1000
- This statement defines an assembler constant called
KERNEL_OFFSET
with the value0x1000
which we will use later on when loading the kernel into memory and jumping to its entry point.
mov [BOOT_DRIVE], dl ; Remember that the BIOS sets us the boot drive in 'dl' on boot
- The line
mov [BOOT_DRIVE], dl
is used to store the value of the DL register into a memory location labeledBOOT_DRIVE
. This is important because the BIOS sets theDL
register to the drive number from which the system booted. By storing this value, the bootloader can later reference the boot drive when necessary, for example, to read additional sectors from the same drive.
mov bp, 0x9000
mov sp, bp
- These instructions are used to set up the stack in assembly language.
mov bp, 0x9000
: This instruction moves the value0x9000
into theBP
(base pointer) register. The value0x9000
is typically chosen to point to a location in memory that is safe to use for the stack. This value is arbitrary but chosen to avoid areas of memory that might be used for other purposes by the bootloader or operating system.0x9000
is chosen to make sure we are far away enough from our other boot loader related memory to avoid collisions.mov sp, bp
: This instruction copies the value in the BP register into the SP (stack pointer) register. Since BP was just set to 0x9000, SP is now also set to 0x9000. The SP register points to the current top of the stack, so setting it to0x9000
initialize the stack at start at this memory location.
- Setting up the stack in this way is important because it ensures that the stack has a known, safe starting point. The stack is used for function calls, storing return addresses, local variables, and handling interrupts, so it needs to be correctly initialized.
- Stack will be used, e.g., by the
call
andret
statements. - Note: Stack grows downwards (high to low memory).
What would happen if we don't set the Stack:
- Unpredictable Behavior: The stack pointer (SP) might be pointing to an unknown location in memory, which could cause your program to overwrite critical data or execute invalid instructions, leading to crashes or undefined behavior.
- Corrupt Return Addresses: Function calls rely on the stack to store return addresses. If the stack is not set up correctly, the return addresses could be stored in the wrong place, causing the program to return to incorrect locations and leading to unpredictable execution flow.
- Local Variables Issues: Local variables in functions are often stored on the stack. Without a properly initialized stack, local variables may corrupt other data or be corrupted themselves, resulting in erratic program behavior.
- Interrupt Handling: If interrupts are enabled and the stack is not set up properly, the CPU might push register values and return addresses onto an invalid or unintended stack location during an interrupt, leading to system instability or crashes when trying to handle interrupts.
- Parameter Passing: In many calling conventions, parameters to functions are passed on the stack. Without a properly initialized stack, parameters might not be passed correctly, causing functions to operate on incorrect data.
MBR without Stack Setup:
[BITS 16]
ORG 0x7C00
start:
; No stack setup here
; Trying to call a function
call print_message
; Infinite loop to halt execution
jmp $
print_message:
mov si, message
.print_char:
lodsb
or al, al
jz .done
mov ah, 0x0E
int 0x10
jmp .print_char
.done:
ret
message db 'Hello, World!', 0
times 510-($-$$) db 0
dw 0xAA55
MBR with Stack Setup
[BITS 16]
ORG 0x7C00
start:
; Set up the stack
mov bp, 0x9000
mov sp, bp
; Now call the function
call print_message
; Infinite loop to halt execution
jmp $
print_message:
mov si, message
.print_char:
lodsb
or al, al
jz .done
mov ah, 0x0E
int 0x10
jmp .print_char
.done:
ret
message db 'Hello, World!', 0
times 510-($-$$) db 0
dw 0xAA55
In the first example, without stack setup:
- The call print_message instruction pushes the return address onto an uninitialized stack. If the SP register is pointing to an invalid location, this could overwrite important data or cause the program to crash when it tries to return from
print_message
.
In the second example, with stack setup:
- The stack pointer is explicitly set to
0x9000
, a known safe location. The call print_message instruction pushes the return address onto this properly initialized stack. Whenprint_message
returns, the CPU correctly retrieves the return address from the stack, ensuring proper execution flow.
mov bx, MSG_16BIT_MODE
call print16
call print16_nl
mov bx, MSG_16BIT_MODE
: This instruction moves the address of the stringMSG_16BIT_MODE
into theBX
register. TheBX
register is then used as a pointer to the string data.call print16
: This instruction calls theprint16
function, which is responsible for printing the string pointed to by theBX
register to the screen in 16-bit real mode.call println_nl
: This instruction calls theprint16_nl
function, which prints a newline (carriage return and line feed) to the screen, ensuring the next output starts on a new line.
call load_kernel ; read the kernel from disk
- This instruction calls the function
load_kernel
which reads the disk (usingint 0x13
bios interrupt) and loads the kernel from the disk into memory at theKERNEL_OFFSET
address (0x1000
). - Loading the Boot Drive: The boot drive number is stored in dl, set by the BIOS, and used to read from the correct disk.
- Reading the Kernel: The int 0x13 BIOS interrupt is used to read a sector from the disk. The parameters for the read operation (drive number, head, cylinder, sector, etc.) are set up in the registers.
- Error Handling: If the carry flag is set after the interrupt call, it indicates an error. The code jumps to
read_error
to print an error message and halt the system.
call switch_to_32bit ; disable interrupts, load GDT, etc. Finally jumps to 'BEGIN_PM'
After done with loading of the kernel, we call the function switch_to_32bit
.
- It prepares everything needed in order to switch to 32 bit protected mode.
- Perform switch, do far jump to 32 bit segment code.
Reading from Disk (disk.asm)
BIOS interrupts provide a convenient and standardized interface for accessing disk I/O functionality, making the process much simpler for bootloader developers. Without BIOS interrupts, bootloader development would indeed be significantly more complex, requiring direct interfacing with I/O devices such as hard disks or floppy drives.
; load 'dh' sectors from drive 'dl' into ES:BX
disk_load:
pusha
; reading from disk requires setting specific values in all registers
; so we will overwrite our input parameters from 'dx'. Let's save it
; to the stack for later use.
push dx
mov ah, 0x02 ; ah <- int 0x13 function. 0x02 = 'read'
mov al, dh ; al <- number of sectors to read (0x01 .. 0x80)
mov cl, 0x02 ; cl <- sector (0x01 .. 0x11)
; 0x01 is our boot sector, 0x02 is the first 'available' sector
mov ch, 0x00 ; ch <- cylinder (0x0 .. 0x3FF, upper 2 bits in 'cl')
; dl <- drive number. Our caller sets it as a parameter and gets it from BIOS
; (0 = floppy, 1 = floppy2, 0x80 = hdd, 0x81 = hdd2)
mov dh, 0x00 ; dh <- head number (0x0 .. 0xF)
; [es:bx] <- pointer to buffer where the data will be stored
; caller sets it up for us, and it is actually the standard location for int 13h
int 0x13 ; BIOS interrupt
jc disk_error ; if error (stored in the carry bit)
pop dx
cmp al, dh ; BIOS also sets 'al' to the # of sectors read. Compare it.
jne sectors_error
popa
ret
disk_error:
mov bx, DISK_ERROR
call print16
call print16_nl
mov dh, ah ; ah = error code, dl = disk drive that dropped the error
call print16_hex ; check out the code at http://stanislavs.org/helppc/int_13-1.html
jmp disk_loop
sectors_error:
mov bx, SECTORS_ERROR
call print16
disk_loop:
jmp $
DISK_ERROR: db "Disk read error", 0
SECTORS_ERROR: db "Incorrect number of sectors read", 0
- This
disk_load
function readsdh
sectors from drivedl
into the memory location pointed to byES:BX
. - It utilizes BIOS interrupt
0x13
to perform disk I/O operations. - It also includes error handling in case of disk read errors or an incorrect number of sectors read.
Recap:
Register | Parameter |
ah | Mode (0x02 = read from the disk) |
al | Number of sectors to read |
ch | Cylinder |
cl | Sector |
dh | Head |
dl | Drive |
es:bx | Memory address to load into (buffer address pointer) |
If there are disk errors, BIOS will set the carry bit. In that case we should usually show an error message to the user.
The main parts of this file is the disk_load
procedure.
- The memory location to place the read data into (
bx
) - The number of sectors to read (
dh
) - The disk to read from (
dl
)
- First thing every procedure should do is to push all general purpose registers (ax, bx, cx, dx) to the stack using
pusha
so we can pop them back before returning in order to avoid side effects of the procedure. - Additionally we are pushing the number of sectors to read (which is stored in the high part of the
dx
register) to the stack because we need to setdh
to the head number before sending the BIOS interrupt signal and we want to compare the expected number of sectors read to the actual one reported by BIOS to detect errors when we are done. - Now we can set all required input parameters in the respective registers and send the interrupt. Keep in mind that bx and dl are already set correctly by the caller. As the goal is to read the next sector on disk, right after the boot sector, we will read from the boot drive starting at sector 2, cylinder 0, head 0.
- After the
int 0x13
has been executed, our kernel should be loaded into memory. To make sure there were no problems, we should check two things: First, whether there was a disk error (indicated by the carry bit) using a conditional jump based on the carry bitjc disk_error
. Second, whether the number of sectors read (set as a return value of the interrupt inal
) matches the number of sectors we attempted to read (popped from stack intodh
) using a comparison instructioncmp al, dh
and a conditional jump in case they are not equaljne sectors_error.
- In case something went wrong we will run into an infinite loop. If everything went fine, we are returning from the procedure back to the main function. The next task is to prepare the GDT so we can switch to 32 bit protected mode.
Global Descriptor Table (GDT)
In 16-bit real mode, memory segmentation works differently compared to protected mode. In protected mode, memory segments are defined by segment descriptors, which are part of the Global Descriptor Table (GDT).
Here's how memory segmentation works in protected mode:
- Segment Descriptors: Memory segments are defined by segment descriptors. These descriptors contain information about the base address, limit, access rights, and other attributes of the memory segment.
- Global Descriptor Table (GDT): The GDT is a table that contains segment descriptors. Each entry in the GDT describes a memory segment.
- Selector: Instead of directly specifying a segment's base address and limit, in protected mode, you use a selector. A selector is an index into the GDT.
- Segment Register: To use a memory segment, you load its selector into a segment register (e.g., CS, DS, ES, SS). Once loaded, the segment register points to the segment descriptor in the GDT.
In our boot loader, we will set up the simplest possible Global Descriptor Table (GDT), resembling a flat memory model. This GDT will have the following structure:
- Null Segment Descriptor:
- This serves as a safety mechanism to catch errors where our code forgets to select a memory segment, thus yielding an invalid segment as the default one.
- Code Segment Descriptor (4 GB):
- This descriptor defines a code segment spanning the complete 4 GB of addressable memory.
- Data Segment Descriptor (4 GB):
- This descriptor defines a data segment overlapping with the code segment and spanning the complete 4 GB of addressable memory.
gdt_start: ; don't remove the labels, they're needed to compute sizes and jumps
; the GDT starts with a null 8-byte
dd 0x0 ; 4 byte
dd 0x0 ; 4 byte
; GDT for code segment. base = 0x00000000, length = 0xfffff
; for flags, refer to os-dev.pdf document, page 36
gdt_code:
dw 0xffff ; segment length, bits 0-15
dw 0x0 ; segment base, bits 0-15
db 0x0 ; segment base, bits 16-23
db 10011010b ; flags (8 bits)
db 11001111b ; flags (4 bits) + segment length, bits 16-19
db 0x0 ; segment base, bits 24-31
; GDT for data segment. base and length identical to code segment
; some flags changed.
gdt_data:
dw 0xffff ; segment length, bits 0-15
dw 0x0 ; segment base, bits 0-15
db 0x0 ; segment base, bits 16-23
db 10010010b ; flags (8 bits)
db 11001111b ; flags (4 bits) + segment length, bits 16-19
db 0x0 ; segment base, bits 24-31
gdt_end:
; GDT descriptor
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; size (16 bit), always one less of its true size
dd gdt_start ; address (32 bit)
; define some constants for later use
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
Switching to Protected Mode
In order to switch to 32 bit protected mode, so that we can hand over control to our 32 bit kernel, below are the steps to be done:
- Disable interrupts using the
cli
instruction. - Load the GDT descriptor into the GDT register using the
lgdt
instruction. - Enable protected mode in the control register
cr0
. - Far jump into our code segment using
jmp
. This needs to be a far jump so it flushes the CPU pipeline, getting rid of any prefetched 16 bit instructions left in there. - Setup all segment registers (
ds
,ss
,es
,fs
,gs
) to point to our single 4 GB data segment. - Setup a new stack by setting the 32 bit bottom pointer (
ebp
) and stack pointer (esp
). - Jump back to
mbr.asm
and give control to the kernel by calling our 32 bit kernel entry procedure.
[bits 16]
switch_to_32bit:
cli ; 1. disable interrupts
lgdt [gdt_descriptor] ; 2. load the GDT descriptor
mov eax, cr0
or eax, 0x1 ; 3. set 32-bit mode bit in cr0
mov cr0, eax
jmp CODE_SEG:init_32bit ; 4. far jump by using a different segment
[bits 32]
init_32bit: ; we are now using 32-bit instructions
mov ax, DATA_SEG ; 5. update the segment registers
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000 ; 6. update the stack right at the top of the free space
mov esp, ebp
call BEGIN_32BIT ; 7. Call a well-known label with useful code
;; code in mbr.asm
[bits 32]
BEGIN_32BIT:
mov ebx, MSG_32BIT_MODE
call print32
call KERNEL_OFFSET ; Give control to the kernel
jmp $ ; Stay here when the kernel returns control to us (if ever)
Writing a Dummy C Kernel
Transitioning to protected mode opens up the possibility of writing code in higher-level languages like C. We can start by writing a simple C function that we can call from our bootloader. However, it's important to remember that once we leave 16-bit real mode, we won't have the BIOS at our disposal anymore, and we'll need to write our own I/O drivers.
For the time being, the task of our dummy kernel will be to output the letter J
in the top left corner of the screen. To do that we will modify the video memory directly. In color displays with VGA text mode enabled, the memory starts at address 0xb8000
.
Each character consists of 2 bytes
- The first byte represents the ASCII encoded character.
- The second byte contains color information.
Below is the simple main
function inside kernel.c
that prints an J
in the top left corner of the screen.
void kernel_main() {
char* video_memory = (char*) 0xb8000;
*video_memory = 'J';
}
or
void kernel_main() {
// Pointer to the video memory
char* video_memory = (char*) 0xb8000;
// Character to display
char character = 'J';
// Attribute byte: white text on black background
char attribute_byte = 0x0F;
// Calculate the memory offset
int offset = 0;
// Set the character at the top left corner of the screen
video_memory[offset] = character;
video_memory[offset + 1] = attribute_byte;
}
Kernel Entry
We have not called the kernel_main
function yet. We need to call it in the mbr.asm
Wrapping up
# $@ = target file
# $< = first dependency
# $^ = all dependencies
# First rule is the one executed when no parameters are fed to the Makefile
all: run
kernel.bin: kernel-entry.o kernel.o
ld -m elf_i386 -o $@ -Ttext 0x1000 $^ --oformat binary
kernel-entry.o: kernel-entry.asm
nasm $< -f elf -o $@
kernel.o: kernel.c
gcc -m32 -fno-pic -ffreestanding -c $< -o $@
mbr.bin: mbr.asm
nasm $< -f bin -o $@
os-image.bin: mbr.bin kernel.bin
cat $^ > $@
run: os-image.bin
qemu-system-i386 -fda $<
clean:
$(RM) *.bin *.o *.dis