CLOSE

In the previous chapter, we crafted a simple stage 1 flat binary bootloader, which is exactly 512 bytes in size (equivalent to one sector). This bootloader is loaded by the BIOS at memory location 0x7C00 and displays a welcome message on the screen. In this chapter we will enter in stage 2 land. The stage 2 would be in the same output format as of stage 1 which is flat binary (with no headers), it can be loaded and executed directly as soon as it is loaded by jumping to the starting memory address after it is loaded by the stage 1 bootloader.

1️⃣ Memory Map in Real Mode:

In real mode, the CPU behaves much like the original 8086 processor, with a 20-bit address space and a simple memory model.

  • 20-bit Address Space:
    • In real mode, the CPU has access to a 1 MB (1024 KB) address space (from 0x00000 to 0xFFFFF).
    • Addresses are formed by combining a segment and offset. The segment is shifted left by 4 bits (multiplied by 16), and the offset is added to it, giving a 20-bit physical address.
  • Example:
Segment:Offset → Physical Address
0x1234:0x5678 → (0x1234 << 4) + 0x5678 = 0x179B8
image-228.png

Here is the memory map for our bootloader:

; Memory Map:
; 0x00000000 - 0x000004FF		Reserved
; 0x00000500 - 0x00007AFF		Second Stage Bootloader (~29 Kb)
; 0x00007B00 - 0x00007BFF		Stack Space (256 Bytes)
; 0x00007C00 - 0x00007DFF		Bootloader (512 Bytes)
; 0x00007E00 - 0x00008FFF		Used by subsystems in this bootloader
; 0x00009000 - 0x00009FFF		Memory Map
; 0x0000A000 - 0x0000AFFF		Vesa Mode Map / Controller Information
; 0x0000B000 - 0x0007FFFF		File Loading Bay

2️⃣ Things We will Do in this Chapter:

In this chapter, we will focus on developing the second stage of our bootloader. Our goal is to load a second stage file from the disk and transfer control to it. The second stage file will be loaded into memory and executed to further the boot process. We would use the BIOS function to load the stage 2 from the stage 1.

Our stage2 will be of size 29 KB (30207 bytes). Why?

Because we will be loading it to the location 0x0500 and we have 29 KB sized usable memory available here.

0x7AFF - 0x0500 = 0x75FF = 30207 Bytes = 29 KB.

We will pad the stage2 to be 29KB in exact, we will fill the extra space with zeroes using times instruction.

How we will read it and load it?

We will make use of BIOS functions to read from the disk image and load it at particular location. In the disk image our stage 2 would be at sector 1 and stage 2 starts from the sector 2 and spans up to sector 59.

Below is the layout of the disk image:

image-225.png

🪜 Steps-by-Steps Process:

Step 1: Writing the Stage 2 File

The stage 2 file will:

  • Start in 16-bit mode.
  • Be loaded at memory location 0x500
  • Our stage 2 would be of size 29 KB.
    • From 0x00000500 to 0x00007AFF
    • Will pad the extra data to make it complete 29 KB.
  • Print Welcome Message.

Step 2: Enhancing the Stage 1 File

We will enhance the stage 1 bootloader to:

  1. Load the stage 2 file from the disk (located at sector 2) using disk read BIOS function (Interrupt 0x13).
    • The stage 2 code starts at sector 2 and ends at the sector 58.
  2. Load the stage 2 file into memory location 0x500.
  3. After successfully loading stage 2, it jumps to the address 0x500 to transfer control to the stage 2 code.
  4. In case of a disk read error, it halts the system in an infinite loop.
image-226.png

Step 3: Assemble the Stage 1 and Stage 2 Files

Assemble the both stages assembly files into flat binary format. By concatenating both binary into a single one.

Step 3: Writing to the Binary File into Disk Image

  • Write the stage 1 to first sector of the disk.
  • Write the stage 2 at the starting of the second sector and take up to 29KB * 1024 = 29,696 Bytes.
    • Start at sector 2.
    • Offset = 1.
    • Maximum Size of stage 2 could be:
      • 29 KB = 29,696 Bytes.
      • Total disk sectors that it will cover = Size in Bytes / Size of 1 Sector in Bytes
        29696 / 512 = 58 Sectors.

3️⃣ Code Implementation:

3.1 Common Include Files to Both Stage 1 and Stage 2:

Common/defines.inc:

This file will consists of various variables and constants definition, which would be common to both stages:

%ifndef _COMMON_DEFINES_INC_
%define _COMMON_DEFINES_INC_

%define		STAGE_2_LOAD_ADDRESS	0x0500
%define		STAGE_2_SIZE			30207	; 0x7AFF - 0x500 = 0x75FF = 30,207
%define		STAGE_2_SECTORS_COUNT	STAGE_2_SIZE/512	; 30,207 / 512(size of 1 sector) = 58

%endif

common/print16.inc:

This file contains the common displaying code in the real mode for both stage 1 and stage 2.

Functions:

  • ClearScreenAndResetCursor: Clears the screen and reset the cursor to (0, 0).
  • PrintChar16BIOS: Prints the char on the screen using interrupt.
    • Input:
      • AL = Character to print
  • PrintString16BIOS: Prints the null terminated string on the screen using interrupt.
    • Input:
      • SI = Null Terminated String
  • PrintNumber: Prints the decimal value.
    • Input:
      • EAX = Number to print
  • SetTextColor: Set the bTextColor variable which is used in the prints function.
    • Input:
      • AL = Background color
      • AH = Text color
    • Example Usage:
      • ; Set text color
        mov     al, BLACK
        mov     ah, GREEN
        call     SetTextColor
%ifndef _COMMON_PRINT_16_INC_
%define _COMMON_PRINT_16_INC_

; 16 Bit Code
BITS 16

; Definitions
;;*********************************************
;; To be used by the SetTextColor Function
;; Example usage:->
;;; ; Set text color
;;; mov 	al, BLACK
;;;	mov 	ah, GREEN
;;;call 	SetTextColor

%define 		BLACK		0x0
%define 		BLUE 		0x1
%define 		GREEN 		0x2
%define 		CYAN 		0x3
%define 		RED 		0x4
%define 		MAGENTA 	0x5
%define 		BROWN 		0x6
%define 		LGRAY 		0x7
%define 		DGRAY		0x8
%define 		LBLUE		0x9
%define 		LGREEN		0xA
%define 		LCYAN		0xB
%define 		LRED		0xC
%define 		LMAGENTA	0xD
%define 		YELLOW		0xE
%define 		WHITE		0xF
;;*********************************************


; ********************************
; ClearScreenAndResetCursor
; This function clears the screen and resets the cursor at default (0,0) position

	pusha
	mov ah, 0x06		; BIOS Function: scroll up
	mov al, 0			; Number of lines to scroll (0 = clear entire screen)
	mov bh, 0x07		; Attribute for blank lines (light gray on black)
	mov cx, 0x0000		; CH = 0, CL = 0 (upper left corner)
	mov dx, 0x184F		; End at the bottom right (row 24, column 79)
	int 0x10			; Call BIOS interrupt
	
	; Reset cursor position to the top-left corner
	mov ah, 0x02		; BIOS function: Set cursor position
	mov bh, 0x00		; Page number (0)
	mov dh, 0x00		; Row (0)
	mov dl, 0x00		; Column (0)
	int 0x10			; Call BIOS interrupt

	popa
ret

; ********************************
; PrintChar16BIOS
; This function prints the char on the screen stored in AL register using the BIOS interrupts.
; IN:
; 	- AL: Char
; ********************************
PrintChar16BIOS:
	; Save the state of AX and BX registers
	push 	ax
	push 	bx

	; Setup for BIOS teletype output (INT 0x10, AH = 0x0E)
	mov 	ah, 0x0E               ; BIOS teletype function (TTY output)
	mov 	bl, byte [bTextColor]  ; Load the text color into BL
	mov 	bh, 0x00               ; Set the page number to 0 (usually not needed for TTY output)
	int 	0x10        ; BIOS video interrupt to print the character in AL

	; Restore the state of BX and AX registers
	pop 	bx
	pop 	ax
	ret            ; Return from the PrintChar16BIOS routine


; ********************************
; PrintString16BIOS

; This function prints the string referenced by the SI register using BIOS interrupts.
; IN:
; 	- SI: NULL-Terminated String
; ********************************
PrintString16BIOS
:
	; Save all general-purpose registers
	pushad

	; Loop to process each character in the string
	.cLoop16BIOS:
		; Load the next byte from the string (pointed to by SI) into AL
		lodsb

		; Check if the loaded byte is NULL (end of string)
		or	al, al
		jz	.PrintDone  ; If AL is zero, jump to the end of the print routine

		; Print the character in AL
		call PrintChar16BIOS
		jmp	.cLoop16BIOS  ; Repeat the loop for the next character

	.PrintDone:
		; \n
		; Move cursor to the beginning of the next line (equivalent of \n)
		mov ah, 0x02	; BIOS function: Set Cursor position
		inc dh			; Move cursor down to one line
		mov dl, 0x00	; MOve cursor to the beginning of the line (column 0)
		int 0x10		; Set cursor position to (DL, DH)

		; Restore all general-purpose registers
		popad
ret  ; Return from the PrintString16BIOS routine


; ********************************
; PrintNumber
; IN:
; 	- EAX: NumberToPrint
; ********************************
PrintNumber:
	; Save all general-purpose registers
	pushad

	; Initialize variables
	xor 	ebx, ebx        ; Clear EBX to use it as a counter for the number of digits
    mov 	ecx, 10         ; Set ECX to 10, the divisor for converting number to digits

	.DigitLoop:
	    xor 	edx, edx        ; Clear EDX before division
	    div 	ecx             ; Divide EAX by 10
	    ; After div: EAX contains quotient, EDX contains remainder

	    ; Convert remainder to ASCII
	    add 	edx, 48         ; Convert digit to ASCII ('0' = 48)

	    ; Store ASCII digit on the stack
	    push 	edx
	    inc 	ebx             ; Increment digit count

	    ; If quotient (EAX) is zero, we're done converting digits
	    cmp eax, 0
	    jnz .DigitLoop       ; If EAX is not zero, repeat the loop

	.PrintLoop:
		; Pop ASCII digit from stack into EAX
		pop 	eax

		; Print the character in EAX
		call 	PrintChar16BIOS

		; Decrease digit count in EBX
		dec 	ebx
		jnz 	.PrintLoop     ; If EBX is not zero, print next digit

    ; Restore all general-purpose registers
    popad
    ret                       ; Return from the PrintNumber routine


; ********************************
; SetTextColor
; IN:
; 	- AL: Background Color
;	- AH: Text Color
; ********************************
SetTextColor:
	; Save the state of AX and BX registers
	push 	ax
	push 	bx

	; Pack AH (Text Color) and AL (Background Color) into BL
	mov 	bl, ah          ; Move the text color (AH) into BL
	shl 	bl, 4           ; Shift BL left by 4 bits to make space for the background color
	and 	al, 0x0F        ; Ensure AL only contains the lower 4 bits (0-15) for the background color
	or 		bl, al          ; Combine BL (shifted text color) and AL (background color)

	; Store the combined color in bTextColor
	mov 	byte [bTextColor], bl

	; Restore the state of BX and AX registers
	pop 	bx
	pop 	ax
	ret          ; Return from the SetTextColor routine


; ********************************
; Variables
; ********************************

bTextColor	db	0x0F	; b - byte sized variable

%endif

common/disk.inc:

This file consists the code for reading the disk.

  • ReadFromDisk: Read specified sector range data from the specified disk and load at the specified buffer. It uses Interrupt 0x13.
    • Input:
      • DL = Drive number
      • CH = Cylinder number
      • DH = Head number
      • CL = Sector Number (starting sector 1 based)
      • ES:BX = Memory address to load data to
      • AL = Number of sectors to read

Get more information of interrupt 0x13 disk reading function, here: https://thejat.in/learn/clear-screen-using-int-in-real-mode

%ifndef _COMMON_DISK_INC_
%define _COMMON_DISK_INC_

; 16 Bit Code
BITS 16

; ********************************
; ReadFromDisk
; IN:
; 	- DL: Drive number (0x00 = floppy A:, 0x80 = first HDD, etc.)
;	- CH: Cylinder number
;	- DH: Head number
;	- CL: Sector number (1-63)
;	- ES:BX: Memory address to load data to
;	- AL: Number of sectors to read
; ********************************
ReadFromDisk:
	; Save state
	pushad

	; Prepare for BIOS disk read
	mov 	ah, 0x02          ; BIOS function: Read sectors
	int 	0x13              ; Call BIOS disk interrupt

	; Check for errors
	jc 	.DiskReadError       ; If carry flag is set, jump to error handler

	; Restore state & return
	popad
	ret

.DiskReadError:
	; Handle disk read error (could be retry logic or error message)
	mov si, diskErrorMessage
	call PrintString16BIOS
	hlt
	jmp 	.DiskReadError
	
diskErrorMessage db 'Disk read error!`, 0

%endif

Explanation of Parameters:

  • DL: The drive number, such as 0x00 for the first floppy drive or 0x80 for the first hard disk.
  • CH: The cylinder number on the disk.
  • DH: The head number on the disk.
  • CL: The sector number on the disk, which typically starts at 1.
  • ES:BX: The memory segment and offset where the data will be loaded.
  • AL: The number of sectors to read.

3.2 Modify boot/stage1/stage1.asm:

Now we need to modify the stage 1 code to read stage 2 from disk and load it to known memory address which will be 0x0500 and do the jump to this location.

; 16 Bit Code, Origin at 0x0
BITS 16
ORG 0x7C00
jmp main

; Includes
%include "boot/common/defines.inc"
%include "boot/common/print16.inc"	; For printing functions
%include "boot/common/disk.inc"		; For disk read function


main:
	; Disable Interrupts, unsafe passage
	cli

	; Far jump to fix segment registers
	jmp 	0x0:FixCS

FixCS:
	; Fix segment registers to 0
	xor ax, ax
	mov	ds, ax
	mov	es, ax

	; Set stack
	; The sp register is used to point to the top of the stack. By setting sp to 0x7C00, the bootloader ensures that the stack starts at the top of the memory allocated for the bootloader. This is important because the stack grows downward in memory, so it's set up before any other code runs.
	mov	ss, ax
	mov	ax, 0x7C00	; It ensure that there's space for the stack to grow downward without overlapping with other code or any other data in memory.
	mov	sp, ax

	; set interrupts
	sti

	; Save the DL register value, which contains the disk number 
	mov 	byte [bPhysicalDriveNum], dl

	call ClearScreenAndResetCursor	; Clear the screen with light gray on black and reset the cursor

	;Print Welcome to the Screen
	mov si, WelcomeToStage1		; Load the address of the string into si register
	call PrintString16BIOS		; String printing function.
	
	; Load stage from the disk
	mov	dl, [bPhysicalDriveNum]	; Drive number
	mov ch, 0		; Cylinder number
	mov dh, 0		; Head number
	mov cl, 2		; Sector starting (1 Based Indexing, first sector is at index 1 which is boot sector)
	mov ax, 0x0000
	mov es, ax
	mov bx, STAGE_2_LOAD_ADDRESS	; Memory address (0x500)
	mov al, STAGE_2_SECTORS_COUNT	;58 = Number of sectors to read
	call ReadFromDisk	; Call the routine to read from disk

	; Enter to Stage 2 land
	jmp STAGE_2_LOAD_ADDRESS

	; Infinite loop
	jmp $

; **************************
; Variables
; **************************
bPhysicalDriveNum	db	0	; Define variable to store disk number	

WelcomeToStage1	db 'Welcome to the Stage1', 0	; Define welcome message

; Fill out bootloader
times 510-($-$$) db 0		; Fill up the remaining space with zeroes

; Boot Signature
db 0x55, 0xAA		; Boot signature indicating valid bootloader

3.3 Add stage 2 code:

stage2.asm:

As we discussed above that stage 1 will load our stage 2 at memory address 0x0500, so we need to set our stage 2 code origin to 0x500. Our stage 2 code as of now just prints the welcome message by using the print function which we have used in stage 1. For them we need to include common/print16.inc in our stage 2 file.

  • Our stage 2 boot code will be 29 KB in size, as 0x0500 to 0x7AFF it is 30207 bytes (29 KB) long which is more than enough for our stage 2 bootloader for the time being.
  • So in the stage 1 we read 29 KB of data from the disk which are 30207 / 512 = 58 sectors and loaded them at the starting of address 0x0500.
  • You guys might be wondering isn't 29 KB is much much larger for our stage 2 code which just prints the welcome which should not take more than few bytes. Then what about the rest of space? We will pad them zero using the times instruction.
BITS 16

%include "boot/common/defines.inc" ; For STAGE_2_LOAD_ADDRESS definition

ORG STAGE_2_LOAD_ADDRESS	; 0x500

; Memory Map:
; 0x00000000 - 0x000004FF		Reserved
; 0x00000500 - 0x00007AFF		Second Stage Bootloader (~29 Kb)
; 0x00007B00 - 0x00007BFF		Stack Space (256 Bytes)
; 0x00007C00 - 0x00007DFF		Bootloader (512 Bytes)
; 0x00007E00 - 0x00008FFF		Used by subsystems in this bootloader
; 0x00009000 - 0x00009FFF		Memory Map
; 0x0000A000 - 0x0000AFFF		Vesa Mode Map / Controller Information
; 0x0000B000 - 0x0007FFFF		File Loading Bay

jmp stage2_entry

; Includes
%include "boot/common/print16.inc"

stage2_entry:
	mov si, WelcomeToStage2
	call PrintString16BIOS

jmp $

; **********
; Variables
; **********

WelcomeToStage2 db 'Welcome to the Stage2', 0
times STAGE_2_SIZE - ($-$$) db 0		; Fill up the remaining space with zeroes

3.4 Makefile:

The makefile includes the recipe for the assembling, concatenating, and running the code.

# $@ = target file
# $< = first dependency
# $^ = all dependencies

BOOT_STAGE_INCLUDE = boot/common

all: build_dir boot run

build_dir:
	mkdir -p build

stage1.bin: boot/stage1/stage1.asm
	nasm -f bin -I $(BOOT_STAGE_INCLUDE) -o build/$@ $<

stage2.bin: boot/stage2/stage2.asm
	nasm -f bin -I $(BOOT_STAGE_INCLUDE) -o build/$@ $<

# concatenate the both stages of bootloader
boot: stage1.bin stage2.bin
	cat build/stage1.bin build/stage2.bin > build/boot.bin

# Optional creation of disk.img
disk.img:
	@echo "Creating the disk.img"
	# Create a blank 1.44MB disk image
	dd if=/dev/zero of=build/disk.img bs=512 count=2880

	# Write stage 1 bootloader to the first sector
	dd if=build/stage1.bin of=build/disk.img conv=notrunc

	# Write stage 2 bootloader to the sectors starting from the second sector
	dd if=build/stage2.bin of=build/disk.img bs=512 seek=2 conv=notrunc


run:
	qemu-system-x86_64 -drive  format=raw,file=build/boot.bin

# Clean up generated files
clean:
	rm -rf build

Explanation:

In QEMU we can run both the binary as well as disk image file. As of now we were running the flat binary file in QEMU.

We have a lot of ways to test our bootloader in QEMU.

First Method:

  • Append stage 1 and stage 2 in single binary file. Use the appended flat binary file in QEMU, which is shown below:
cat build/stage1.bin build/stage2.bin > build/boot.bin
qemu-system-x86_64 -drive  format=raw,file=build/boot.bin

Second Method:

  • Append stage 1 and stage 2 in single binary file. Then write the appended binary file to the disk image file and run that disk image file in the QEMU.
cat build/stage2.bin >> build/stage1.bin
# This command appends the contents of stage2.bin to stage1.bin
## OR
cat build/stage1.bin build/stage2.bin > build/boot.bin
# this one appends both the contents of stage1 and stage2 in new file boot.bin.

## Now write the generated appended boot.bin to disk.img file
# Create a blank 1.44MB disk image
dd if=/dev/zero of=build/disk.img bs=512 count=2880

# Write the combined bootloader to the first sectors of the disk image
dd if=build/boot.bin of=build/disk.img conv=notrunc
## OR
# if stage2 is appended to stage1 itself, not in new file.
dd if=build/stage1.bin of=build/disk.img conv=notrunc

## Test the Disk Image with QEMU
qemu-system-x86_64 -drive format=raw,file=build/disk.img

Third Method:

The third method is to write both stages of bootloader to disk image file individually.

# Create a blank 2MB disk image
dd if=/dev/zero of=build/disk.img bs=512 count=4096
# This creates a 2MB disk image file (4096*512 bytes = 2097152 bytes = 2048KB = 2MB)

# Write stage 1 bootloader to the first sector
dd if=build/stage1.bin of=build/disk.img count=1 conv=notrunc

# Write stage 2 bootloader to the sectors starting from the second sector
dd if=build/stage2.bin of=build/disk.img bs=512 seek=1 conv=notrunc
  • This makefile does the following:
    • Creates a 1.44MB floppy disk image file filled with zeros.
    • Writes the stage1 to the first 512 bytes of disk.img.
    • Then writes the stage2.bin to the second sector (starting at byte 512) of disk.img.

If in future, we are introducing stage 3 and writing it to the disk image file, just after the stage 2.

  • Sector indexing on disk typically starts from 1 when referring to the logical sectors of a track within a cylinder, especially in BIOS or disk addressing contexts. However, when working with tools like dd, the offset or sector counting starts from 0.
  • Since stage 2 is of size 29KB means 58 sectors. It starts from sector 2 and end at the sector 59.
  • So our stage 3 will start from the sector 59.
dd if=/dev/zero of=disk.img bs=512 count=4096
dd if=stage1.bin of=disk.img bs=512 count=1 conv=notrunc
dd if=stage2.bin of=disk.img bs=512 seek=1 conv=notrunc
dd if=stage3.bin of=disk.img bs=512 seek=59 conv=notrunc
  • This code does the following:
    • Creates a 2MB disk image file (4096 * 512 bytes).
    • Writes stage1.bin to the first 512 bytes (Sector 0) of disk.img.
      • Contains the Stage 1 bootloader.
      • Size: 512 bytes.
    • Writes stage2.bin to the disk starting at Sector 1 (byte 512) to Sector 58 (byte 29,696).
      • Contains the Stage 2 bootloader.
      • Total size: 29 KB (29 * 1024 bytes) = 29,696 bytes.
      • Number of sectors: 29,696 bytes / 512 bytes per sector = 58 sectors
    • Writes stage3.bin to the disk starting at Sector 59 (byte 30,208) to Sector 60 (byte 30,208).

3️⃣ Output

image-150.png

4️⃣ Repository

You can get this complete code with this commit: GitHub - The-Jat/TheTaaJ at a707d86de60d0ab1a942e4f92de5d12600313f16