I am working on building a simple bootloader that loads a kernel from disk into memory at the 1MB mark (0x100000). The bootloader consists of two stages: Stage 1 reads the second stage, and Stage 2 loads the kernel from the disk into memory.
I am testing this with QEMU and checking the memory contents using the QEMU monitor. However, after loading the kernel, the memory at 0x100000 shows all zeros (empty), meaning that the kernel isn't being loaded into memory as expected.
I am using NASM for the bootloader, GCC for compiling the kernel in C, and QEMU for emulation. I've also checked my linker script to ensure the kernel is linked to start at 0x100000. Additionally, I am using Docker to build everything.
stage1.asm:
[BITS 16] ; We are working in 16-bit Real Mode
[org 0x7c00] ; The origin (starting address) of the bootloader in memory, which is 0x7C00 as loaded by the BIOS.
; This is the physical address where the bootloader is loaded into memory.
start: ; Start of execution, this label marks the entry point of the code.
jmp 0x0000:main ; Jump to the 'main' label to start execution.
main: ; Main routine of the bootloader begins here.
; -------------------------
; Setup segment registers
; -------------------------
cld ; Set direction forward (DF=0) for string instructions (STOS, LODS, CMPS etc)
cli ; Clear interrupts to ensure no interrupts occur while setting up segments.
xor ax, ax ; Set AX to 0x0 (which is 0x0 >> 4).
; Explanation: We are using segment:offset addressing in real mode.
; Physical address = Segment * 16 + Offset
; So, the segment 0x0 * 16 = 0x0 (physical address).
mov ds, ax ; Set Data Segment (DS) to 0x0. DS points to the bootloader code/data in memory.
mov es, ax ; Set Extra Segment (ES) to 0x0. ES is also set to point to our code/data.
; -------------------------
; Setup stack
; -------------------------
mov ss, ax ; Set Stack Segment (SS) to 0 (base of memory).
mov sp, 0xFFFE ; Set the Stack Pointer (SP) to the highest address within the current 64KB segment (0x0000:0xFFFE).
; In real mode, the stack grows downward from 0xFFFE and 0xFFFE should be set to an even offset like 0xFFFE, not an odd one.
sti ; Re-enable interrupts after segment and stack setup is complete.
; -------------------------
; Load Stage 2 bootloader from disk
; -------------------------
mov ah, 02h ; BIOS Interrupt 13h, Function 02h: Read sectors from the disk.
mov al, 01h ; Read 1 sector from the disk (this corresponds to the size of a sector, which is 512 bytes).
mov ch, 00h ; Set Cylinder number to 0 (since both Stage 1 and Stage 2 are on Cylinder 0).
mov cl, 02h ; Set Sector number to 2 (Stage 1 is in Sector 1, so Stage 2 starts at Sector 2).
mov dh, 00h ; Set Head number to 0 (assuming we are using Head 0 for now).
mov dl, 80h ; Use the first hard drive (usually 0x80 for the primary hard disk).
mov bx, 0x8000 ; Set BX to 0x8000, the offset address where Stage 2 will be loaded.
; Stage 2 will be loaded into memory using segment:offset addressing.
; ES = 0x0, BX = 0x8000, so the physical address = ES * 16 + BX.
; Formula: 0x0 * 16 + 0x8000 = 0x0 + 0x8000 = 0x8000 (the physical address where Stage 2 is loaded).
int 13h ; Call BIOS interrupt 13h to read the specified sectors into memory.
jc disk_read_error ; If carry flag is set (indicating an error), jump to the error handler.
pass: ; If the disk read was successful (carry flag is cleared), continue from here.
jmp 0x0000:0x8000 ; Jump to the loaded Stage 2 at address 0x0000:0x8000 (this is where Stage 2 resides).
; Here, 0x0000 is the segment, and 0x8000 is the offset.
; Physical address = 0x0000 * 16 + 0x8000 = 0x8000, where Stage 2 is loaded.
disk_read_error:
int 18h ; If the disk read fails, call INT 18h to attempt a boot from a different device (like network boot).
; This error message will occur --> IO write(0x01f0): current command is 20h displayed on bochs emulator.
TIMES 510-($-$$) DB 0 ; Pad the bootloader to ensure it is exactly 512 bytes, with zeros filling the remaining space.
DW 0xAA55 ; The boot signature (magic number) required for the BIOS to recognize this as a bootable sector.
stage2.asm:
[BITS 16]
[org 0x8000]
TSS_ADDR_HI_BYTE_OFFSET equ 5
TSS_ACCESS_BYTE_OFFSET equ 7
jmp a20_enable
; -------------------------
; BSS Section for TSS
; -------------------------
section .bss
tss resb 104 ; Reserve 104 bytes for the TSS (Task State Segment)
; -------------------------
; Initialize the TSS
; -------------------------
section .text
init_tss:
; Set ESP0 (stack pointer for privilege level 0)
mov eax, 0x9FC00 ; Example stack pointer for ring 0
mov [tss + 4], eax ; Set ESP0 in the TSS
; Set SS0 (stack segment for privilege level 0)
mov ax, 0x10 ; Kernel data segment selector (0x10)
mov [tss + 8], ax ; Set SS0 in the TSS
; Swap the byte with tss address bits 24:31 with the access byte
; In the TSS descriptor
mov al, [gdt.tss + TSS_ADDR_HI_BYTE_OFFSET]
xchg al, [gdt.tss + TSS_ACCESS_BYTE_OFFSET]
mov [gdt.tss + TSS_ADDR_HI_BYTE_OFFSET], al
ret ; Return to the main flow
; -------------------------
; A20 Enable
; -------------------------
a20_enable:
pushf ; Save flags
push ds ; Save data segment
push es ; Save extra segment
push di ; Save destination index
push si ; Save source index
cli
in al, 0x92
or al, 2
out 0x92, al
; -------------------------
; A20 Enable Msg
; -------------------------
status_a20_on:
xor ax, ax
mov ds, ax
mov ah, 0x0E ; Set up for character output
mov bh, 0x00 ; Display page number
mov si, msg_a20_enable ; Load success message into SI
print_enable_loop:
lodsb ; Load next byte from message
cmp al, 0 ; Check for null terminator
je restore_registers_a20 ; If end of string, restore registers
int 0x10 ; Print character in AL
jmp print_enable_loop ; Loop to print the next character
restore_registers_a20:
pop si ; Restore source index
pop di ; Restore destination index
pop es ; Restore extra segment
pop ds ; Restore data segment
popf ; Restore flags
sti ; Re-enable interrupts
; -------------------------
; Load kernel from disk
; -------------------------
mov ah, 02h ; BIOS Interrupt 13h, Function 02h: Read sectors from the disk.
mov al, 0Ah ; Read 1 sector from the disk (this corresponds to the size of a sector, which is 512 bytes).
mov ch, 00h ; Set Cylinder number to 0 (since both Stage 1 and Stage 2 are on Cylinder 0).
mov cl, 02h ; Set Sector number to 2 (Stage 1 is in Sector 1, so Stage 2 starts at Sector 2).
mov dh, 00h ; Set Head number to 0 (assuming we are using Head 0 for now).
mov dl, 80h ; Use the first hard drive (usually 0x80 for the primary hard disk).
mov ax, 0x1000 ; Load 0x1000 (segment for 1MB) into AX
mov es, ax ; Move AX (0x1000) into ES
mov bx, 0x0000 ; Offset within the segment
int 13h
jmp gdt_setup
; -------------------------
; GDT Setup and TSS Setup
; -------------------------
gdt_setup:
cli
lgdt [gdt_descriptor] ; Load GDT descriptor into GDTR
push ds
push es
call init_tss ; Initialize the TSS
pop es
pop ds
; -------------------------
; GDT & TSS Success Message
; -------------------------
xor ax, ax
mov ds, ax
mov ah, 0x0E ; Set up for character output
mov bh, 0x00 ; Display page number
mov si, msg_gdtss_success ; Load GDT success message into SI
gdtss_print_success:
lodsb ; Load next byte from message
cmp al, 0 ; Check for null terminator
je enter_protected_mode ; If end of string, proceed to protected mode
int 0x10 ; Print character in AL
jmp gdtss_print_success ; Loop to print the next character
; -------------------------
; Enter Protected Mode
; -------------------------
enter_protected_mode:
cli ; Disable interrupts before entering protected mode
mov eax, cr0
or eax, 1 ; Set protected mode bit in CR0
mov cr0, eax
jmp 0x08:update_segments
; -------------------------
; Protected Mode Code Segment
; -------------------------
[BITS 32]
update_segments:
cli
mov ax, 0x10 ; Load GDT data segment selector (0x10)
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov ebp, 0x90000
mov esp, ebp
mov ax, 0x28 ; TSS selector
ltr ax ; Load Task Register with TSS selector
; -------------------------
; Kernel Code
; -------------------------
kernel:
jmp 0x08:0x100000
; -------------------------
; GDT Descriptor and TSS
; -------------------------
section .data
gdt_start:
gdt:
; Null Descriptor
dd 0x0
dd 0x0
; Kernel Code Segment (DPL = 0)
dw 0xFFFF ; Limit
dw 0x0000 ; Base (lower 16 bits)
db 0x00 ; Base (next 8 bits)
db 10011010b ; Access byte
db 11001111b ; Flags and limit (upper 4 bits)
db 0x00 ; Base (upper 8 bits)
; Kernel Data Segment (DPL = 0)
dw 0xFFFF ; Limit
dw 0x0000 ; Base (lower 16 bits)
db 0x00 ; Base (next 8 bits)
db 10010010b ; Access byte
db 11001111b ; Flags and limit (upper 4 bits)
db 0x00 ; Base (upper 8 bits)
; User Code Segment (DPL = 3)
dw 0xFFFF ; Limit
dw 0x0000 ; Base (lower 16 bits)
db 0x00 ; Base (next 8 bits)
db 11111010b ; Access byte (DPL = 3)
db 11001111b ; Flags and limit (upper 4 bits)
db 0x00 ; Base (upper 8 bits)
; User Data Segment (DPL = 3)
dw 0xFFFF ; Limit
dw 0x0000 ; Base (lower 16 bits)
db 0x00 ; Base (next 8 bits)
db 11110010b ; Access byte (DPL = 3)
db 11001111b ; Flags and limit (upper 4 bits)
db 0x00 ; Base (upper 8 bits)
; TSS Descriptor
.tss:
dw 0x0067 ; Limit
dd tss ; Base
db 00000000b ; Flags and limit (upper 4 bits)
db 10001001b ; Access byte
gdt_end:
; GDT Descriptor (contains size and location of GDT)
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; Size of the GDT (in bytes, minus 1)
dd gdt_start
; -------------------------
; Messages
; -------------------------
msg_a20_enable db 'A20 line enable -> successfully ', 0x0D, 0x0A, 0
msg_gdtss_success db 'GDT and TSS is configured -> successfully ', 0x0D, 0x0A, 0
kernel.c:
#define VIDEO_MEMORY 0xB8000
#define WHITE_ON_BLACK 0x0F
#define ROWS 25
#define COLS 80
void clear_screen() {
volatile unsigned short *video = (unsigned short *) VIDEO_MEMORY;
for (int i = 0; i < ROWS * COLS; i++) {
video[i] = ' ' | (WHITE_ON_BLACK << 8);
}
}
void print_string(const char *message) {
volatile unsigned short *video = (unsigned short *) VIDEO_MEMORY;
while (*message != '\0') {
*video++ = (*message++) | (WHITE_ON_BLACK << 8);
}
}
void _start(void) {
clear_screen();
print_string("Hello, Kernel World!");
while(1);
}
Makefile:
PROJECT_ROOT := $(CURDIR)
STAGE1_SRC := $(PROJECT_ROOT)/Boot/Stage1/stage1.asm
STAGE2_SRC := $(PROJECT_ROOT)/Boot/Stage2/stage2.asm
KERNEL_SRC := $(PROJECT_ROOT)/Kernel/kernel.c
LINKER_SCRIPT := $(PROJECT_ROOT)/linker.ld
STAGE1_BIN := $(PROJECT_ROOT)/Boot/Stage1/stage1.bin
STAGE2_BIN := $(PROJECT_ROOT)/Boot/Stage2/stage2.bin
KERNEL_BIN := $(PROJECT_ROOT)/Kernel/kernel.bin
IMG := $(PROJECT_ROOT)/bootloader.img
QEMU_IMG := qemu-img
DD := dd
# Always clean before building
output: clean $(STAGE1_BIN) $(STAGE2_BIN) $(KERNEL_BIN) $(IMG)
$(STAGE1_BIN): $(STAGE1_SRC)
@echo "Assembling Stage 1..."
nasm -f bin "$(STAGE1_SRC)" -o "$(STAGE1_BIN)"
$(STAGE2_BIN): $(STAGE2_SRC)
@echo "Assembling Stage 2..."
nasm -f bin "$(STAGE2_SRC)" -o "$(STAGE2_BIN)"
$(KERNEL_BIN): $(KERNEL_SRC) $(LINKER_SCRIPT)
@echo "Compiling and linking Kernel..."
i686-elf-gcc -ffreestanding -c "$(KERNEL_SRC)" -o "$(PROJECT_ROOT)/Kernel/kernel.o"
i686-elf-ld -o $(KERNEL_BIN) -T $(LINKER_SCRIPT) $(PROJECT_ROOT)/Kernel/kernel.o
$(IMG): $(STAGE1_BIN) $(STAGE2_BIN) $(KERNEL_BIN)
@echo "Creating bootloader.img..."
$(QEMU_IMG) create -f raw "$(IMG)" 10M
$(DD) if=$(STAGE1_BIN) of=$(IMG) bs=512 count=1
$(DD) if=$(STAGE2_BIN) of=$(IMG) bs=512 seek=1
$(DD) if=$(KERNEL_BIN) of=$(IMG) bs=512 seek=2
clean:
@echo "Cleaning up..."
rm -f "$(STAGE1_BIN)" "$(STAGE2_BIN)" "$(KERNEL_BIN)" "$(IMG)" "$(PROJECT_ROOT)/Kernel/kernel.o"
linker.ld:
ENTRY(_start)
SECTIONS
{
. = 0x100000;
.text : {
*(.text)
}
.rodata : {
*(.rodata)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}
Dockerfile:
FROM ubuntu:latest
RUN apt-get update && apt-get install -y \
wget \
build-essential \
libtool \
pkg-config \
nasm \
qemu-system-x86 \
gcc \
gdb \
make \
bison \
flex \
libgmp3-dev \
libmpc-dev \
libmpfr-dev \
texinfo \
&& rm -rf /var/lib/apt/lists/*
# Download and build the i686-elf cross-compiler
RUN mkdir /usr/local/cross && cd /usr/local/cross && \
wget https://ftp.gnu.org/gnu/binutils/binutils-2.36.tar.gz && \
tar -xzf binutils-2.36.tar.gz && \
cd binutils-2.36 && \
mkdir build && cd build && \
../configure --target=i686-elf --prefix=/usr/local/cross --disable-nls --disable-werror && \
make && make install && \
cd ../../ && \
wget https://ftp.gnu.org/gnu/gcc/gcc-10.2.0/gcc-10.2.0.tar.gz && \
tar -xzf gcc-10.2.0.tar.gz && \
cd gcc-10.2.0 && \
mkdir build && cd build && \
../configure --target=i686-elf --prefix=/usr/local/cross --disable-nls --enable-languages=c,c++ --without-headers && \
make all-gcc && make all-target-libgcc && \
make install-gcc && make install-target-libgcc
# Set environment variables for cross-compilation
ENV PATH="/usr/local/cross/bin:$PATH"
# Set the working directory for the bootloader project
WORKDIR /usr/src/bootloader
# Copy all files from the current directory to the Docker container
COPY . /usr/src/bootloader
# Compile and run the bootloader and kernel
CMD ["sh", "-c", "make clean && make output && qemu-system-x86_64 -drive format=raw,file=bootloader.img -nographic -serial mon:stdio"]
I expected the kernel to be loaded at 0x100000 and to print "Hello, Kernel World!" to the screen once it executes. However, when inspecting memory using the QEMU monitor (xp /16x 0x100000), the memory at 0x100000 is all zeros, indicating the kernel wasn't loaded.
Here is what I have tried so far:
I confirmed that my bootloader reads from the disk using BIOS interrupt
int 13h.I checked that the
kernel.binis compiled and linked to start at0x100000.I verified the number of sectors the bootloader reads matches the size of the kernel.
I inspected the disk image using a hex editor to ensure the kernel is written to the correct location (sector 2 onwards).
Despite these efforts, the kernel is still not appearing in memory.
0x10000(0x1000:0x0000), not0x100000. That's the segment for 64 KB, not 1 MB. The 16-bit BIOS can't access0x100000as it is outside the first megabyte of memory. If you want your kernel there, you'll have to load the sectors somewhere else and copy them after entering protected mode.0x100000, provided it's less than 64K or so, but you'll have to pass a seg:ofs address like0xffff:0x0010.i686-elf-objcopy -O binary kernel.elf kernel.bin. This has the advantage of having both files. The ELF file can be used for debug information (when using GDB with QEMU) and the binary runs in the emulator from a disk image. I recommend usingobjcopyas well since the binary output from LD using--oformat=binarycan generate unexpected output in certain scenarios.