A minimal custom file system might include:
- Boot Block: The first sector of the disk, containing a small bootstrap loader.
- Superblock: Metadata about the file system (e.g., total size, block size and pointers to other structures).
- Directory Table: A simple mapping between file names and their corresponding file descriptors (or inodes).
- File Data Blocks: Blocks that stores the actual content of files.
In this article, we will create a minimal OS that demonstrates how a BIOS bootloader can load data from a custom filesystem. Our project consists of two parts:
- The Bootloader (stage 1):
- A 512-byte assembly program that the BIOS loads at boot. This bootloader will read from our custom filesystem and print a file's content on the screen.
- The Filesystem Creator:
- A C program that creates a raw disk image. The disk image contains our bootloader at very first sector and a simple custom filesystem spread over several sectors.
The goal is to have a bootable disk image that, when booted wit QEMU, displays contents of the file present in the file system.
Overview of Our Custom Filesystem
Our custom filesystem is extremely simple and consists of the following structure:
- Sector 0:
- Contains the BIOS bootloader (assembly code). This is what BIOS reads from the disk at boot time.
- Sector 1 - Superblock:
- Contains basic filesystem metadata. In our example, this includes:
- A magic number (
0x1234
) to verify the filesystem. - Total number of sectors.
- The sector number where the file table resides.
- A magic number (
- Contains basic filesystem metadata. In our example, this includes:
- Sector 2 - File Table:
- Contains a single file entry. The file entry holds:
- An 11-byte filename.
- The starting sector of the file's data.
- Contains a single file entry. The file entry holds:
- Sector 3 - File Data:
- Contains the actual content of our file (a simple null-terminated string).
- Sector 4 onward:
- We pad these sectors with zeroes to fill out the disk image.
This layout is purposely simple. In a real-world system, you might support files, directories, and more metadata - but this example is perfect for learning the fundamentals.
Let's code it
Part 1: Writing the Bootloader in Assembly
Our bootloader is a 512‑byte program written in 16‑bit assembly. It runs in real mode under BIOS control. Its tasks include:
- Setting up the environment:
- The bootloader initializes the stack and data segments.
- Printing a startup message:
- It prints “Loading FS…” to indicate that the boot process has begun.
- Reading the Filesystem Structures:
- It uses BIOS interrupt 0x13 to:
- Load the Superblock (sector 1) and verify our magic number.
- Load the File Table (sector 2) to obtain the starting sector of the file data.
- Load the File Data (sector indicated by the file table) into a buffer.
- It uses BIOS interrupt 0x13 to:
- Displaying the File’s Content:
Finally, it prints the loaded file data (assumed to be a null‑terminated string).
Below is the complete code for boot.asm
:
; boot.asm
BITS 16
org 0x7C00 ; BIOS loads the boot sector at address 0x7c00
%define FS_MAGIC 0x1234 ; Our custom filesystem magic number
%define BUFFER 0x0500 ; load file data at address 0x7E00
start:
; Set up stack and data segments
cli ; Disable interrupts
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Set up a simple stack at 0x7c00
sti ; Re-enable interrupts
; Print "Loading FS..." message
mov si, load_msg
call print_string
; --- Read the Superblock from Sector 2 ---
xor ax, ax
mov es, ax
mov bx, BUFFER
mov al, 1 ; number of sectors to read
mov ch, 0 ; cylinder 0
mov cl, 2 ; sector 2 (boot sector is sector 1) (1-based indexing)
mov dh, 0 ; head 0
mov dl, 0x80 ; boot drive (first hard disk)
call disk_read
; Verify the magic number from the superblock
; Check magic number in the superblock
cmp word [BUFFER], FS_MAGIC
jne halt ; If the magic number does not match, halt
mov si, valid_superblock
call print_string
; --- Read the File Table from Sector 3 ---
mov bx, BUFFER
mov al, 1 ; read 1 sector
mov ch, 0
mov cl, 3 ; sector 3: file table, (1-based indexing)
mov dh, 0
mov dl, 0x80
call disk_read
mov si, read_file_table
call print_string
; Our file table is a single FileEntry:
; FileEntry structure: 11-byte filename, then 1 byte for start_sector.
; Get the start sector from offset 8.
mov cl, byte [BUFFER + 11]
; --- Read the File Data from the indicated sector ---
mov bx, BUFFER
mov al, 1 ; read 1 sector
mov ch, 0
; cl already holds the file data sector number (from the file table)
mov dh, 0
mov dl, 0x80
call disk_read
; --- Print the file contents (assumed to be a null-terminated string) ---
mov si, BUFFER
call print_string
halt:
cli
hlt ; Halt the CPU
;---------------------------
; print_string: prints a null-terminated string pointed to by SI
;---------------------------
print_string:
lodsb ; Load byte at [SI] into AL, increment SI
cmp al, 0
je .done
mov ah, 0x0E ; BIOS teletype function
int 0x10
jmp print_string
.done:
ret
;---------------------------
; disk_read: reads sectors from disk using BIOS interrupt 0x13.
; Expected registers before call:
; BX = buffer address (physical address; here we use 0x7E00)
; AL = number of sectors to read
; CH = cylinder, CL = sector, DH = head, DL = drive
;---------------------------
disk_read:
push ax
push bx
push cx
push dx
mov ah, 0x02 ; BIOS read sector function
int 0x13
jc disk_fail ; Jump if carry flag is set (error)
pop dx
pop cx
pop bx
pop ax
ret
disk_fail:
mov si, disk_err
call print_string
jmp halt
;---------------------------
load_msg: db "Loading FS...", 0x0d, 0x0a, 0
disk_err: db "Disk read error!",0x0d, 0x0a, 0
valid_superblock: db "Valid SuperBlock", 0x0d, 0x0a, 0
read_file_table: db "Read the file table", 0x0d, 0x0a, 0
; Pad to 510 bytes, then add boot signature 0xAA55
times 510 - ($ - $$) db 0
dw 0xAA55
Explaining the Bootloader:
- Environment Setup:
- The bootloader disables interrupts with cli, clears registers, and sets up the stack. This is crucial for predictable behavior in real mode.
- BIOS Interrupt 0x13:
- This interrupt is used for disk I/O. We set up the registers to read one sector (the superblock, then the file table, then file data) and load it into a fixed memory address (0x7E00).
- Filesystem Verification:
- After reading the superblock, the bootloader compares the magic number stored at BUFFER with FS_MAGIC. If they do not match, it halts—indicating that the disk does not contain our expected filesystem.
- File Table and File Data:
- The bootloader assumes a very simple file table where the filename occupies the first 8 bytes and the 9th byte holds the starting sector for file data. It then reads that sector and prints its content.
- Boot Signature:
- The final two bytes (0xAA55) at the end of the 512-byte sector are required by the BIOS to mark the boot sector as valid.
Part 2: Creating the Filesystem Image Using C
Now that we have our bootloader, we need to prepare a disk image that contains our custom filesystem. We will use a C program to write out a raw disk image (.img
) with the following layout.
- Sector 0: The bootloader (loaded from
boot.bin
). - Sector 1: The superblock, containing the magic number, total sectors, and the sector number for the file table.
- Sector 2: The file table with one file entry (hello.txt) that points to sector 3.
- Sector 3: The file data containing our message.
- Sectors 4–(TOTAL_SECTORS-1): Empty sectors for padding.
Below is the complete code for fs.c
:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define SECTOR_SIZE 512
#define TOTAL_SECTORS 10
#define FS_MAGIC 0x1234
// Use packed structs so the layout is exactly as expected.
typedef struct {
uint16_t magic;
uint8_t total_sectors;
uint8_t root_dir_sector;
} __attribute__((packed)) Superblock;
typedef struct {
char filename[11];
uint8_t start_sector;
} __attribute__((packed)) FileEntry;
// Helper: Write one empty (zero-filled) sector.
void write_empty_sector(FILE *fp) {
uint8_t empty[SECTOR_SIZE] = {0};
fwrite(empty, SECTOR_SIZE, 1, fp);
}
// Create the disk image filesystem.
void create_filesystem(const char *bootloader, const char *disk_image) {
FILE *fp = fopen(disk_image, "wb");
if (!fp) {
perror("Failed to create disk image");
return;
}
// --- Sector 1: Write the bootloader ---
FILE *bl = fopen(bootloader, "rb");
if (!bl) {
perror("Failed to read bootloader");
fclose(fp);
return;
}
uint8_t bootloader_data[SECTOR_SIZE] = {0};
size_t bytes = fread(bootloader_data, 1, SECTOR_SIZE, bl);
if (bytes < SECTOR_SIZE) {
// If bootloader is less than 512 bytes, pad with 0.
memset(bootloader_data + bytes, 0, SECTOR_SIZE - bytes);
}
fwrite(bootloader_data, SECTOR_SIZE, 1, fp);
fclose(bl);
// --- Sector 2: Write the Superblock ---
Superblock sb = {FS_MAGIC, TOTAL_SECTORS, 2}; // file table is in sector 2
uint8_t sector[SECTOR_SIZE] = {0};
memcpy(sector, &sb, sizeof(Superblock));
fwrite(sector, SECTOR_SIZE, 1, fp);
// --- Sector 3: Write the File Table ---
// We add one file entry for "hello.txt" whose data is in sector 3.
FileEntry file = {"hello.txt", 3};
memset(sector, 0, SECTOR_SIZE);
memcpy(sector, &file, sizeof(FileEntry));
fwrite(sector, SECTOR_SIZE, 1, fp);
// --- Sector 4: Write the File Data ---
const char *file_data = "Hello from custom FS!\x00";
memset(sector, 0, SECTOR_SIZE);
memcpy(sector, file_data, strlen(file_data) + 1);
fwrite(sector, SECTOR_SIZE, 1, fp);
// --- Sectors 5 to TOTAL_SECTORS-1: Write empty sectors ---
for (int i = 4; i < TOTAL_SECTORS; i++) {
write_empty_sector(fp);
}
fclose(fp);
printf("Filesystem created successfully: %s\n", disk_image);
}
int main() {
create_filesystem("boot.bin", "fs.img");
return 0;
}
Explaining the Filesystem Creator:
- Bootloader Writing:
- The program opens
boot.bin
(which you created by assembling boot.asm) and writes its 512 bytes to sector 0 of the disk image.
- The program opens
- Superblock:
- We create a Superblock structure that stores our magic number, the total number of sectors (10), and the sector where the file table resides (sector 2). This block is written into sector 1.
- File Table:
- A single FileEntry is created for the file named "hello.txt". The entry specifies that the file’s data starts at sector 3. This is written into sector 2.
- File Data:
- We write a simple null‑terminated string, "Hello from custom FS!", into sector 3.
- Padding:
- Sectors 4 through 9 are filled with zeros to complete the disk image.
Building and Testing the System
Follow these steps to build and test the complete system:
1 Assemble the Bootloader
Make sure you have NASM installed. Then run:
nasm -f bin boot.asm -o boot.bin
This command converts boot.asm
into a raw binary file boot.bin
.
2 Compile the Filesystem Creator
Compile the C program with GCC:
gcc fs.c -o fs_Creator
3 Create the Disk Image
Run the filesystem creator:
./fs_creator
This generates fs.img
with our bootloader and custom filesystem.
4 Boot the Image with QEMU
Finally, test the image using QEMU:
qemu-system-x86_64 -drive format=raw,file=fs.img