DOS Boot Sector
I’ve been interested in writing an OS for a long time now. An OS consists of many components with one of the most fundamental being its booting mechanism. Had I been writing a production OS, I would have made use of a package such as GNU GRUB or LILO. However, as a hobbyist I was interested to know exactly what my PC was doing during the boot process. I decided that a good way to start would be to study a simple operating system – MS-DOS. An MS-DOS boot sector has a very simple job: load the first 3 sectors of IO.SYS into memory and execute it.
After your BIOS completes its POST, an IBM PC compatible computer will read the first 512 B block from disk into memory at location 0x07C00 and begin executing it. The last 2 B of the boot sector must have the value 0xAA55; this value is known as the boot signature. This leaves 510 B for code.
MS-DOS expects the disk to be formatted with the FAT file system and will populate the boot sector with an 8 B OEM name and a 51 B data structure known as the BIOS parameter block. The first 3 B are expected to contain a jump instruction. This finally leaves us with 448 B for code. Had I been writing a production DOS boot sector, I would have written the code in an assembly language under such extreme constraints. However, as a philocalist and masochist I felt compelled to write legible code and decided to use C.
The BIOS parameter block contains important information about the layout of the filesystem. Here is a table describing its layout:
| Length | Name |
|---|---|
| 2 | Bytes per sector |
| 1 | Sectors per cluster |
| 2 | Number of reserved sectors |
| 1 | Number of file allocation tables |
| 2 | Number of root entries |
| 2 | Number of sectors (if < 65 536) |
| 1 | Media descriptor |
| 2 | Sectors per file allocation table |
| 2 | Sectors per track |
| 2 | Number of heads |
| 4 | Number of hidden sectors |
| 4 | Number of sectors (if ≥ 65 536) |
| 1 | Disk drive index |
| 1 | Reserved |
| 1 | Volume signature |
| 4 | Volume ID |
| 11 | Volume label |
| 8 | Volume type |
The CPU will be in real mode when the boot sector is loaded. This means we can only use 16-bit opcodes and address up to 1 MiB of memory. The first 640 KiB are available to our program while the remaining 384 KiB are used for assorted system-specific purposes. These memory areas are known as conventional memory and the upper memory area, respectively.
Some parts of conventional memory are reserved by the system. The first 1 024 B are used for the interrupt vector table and the next 256 B are used for the BIOS data area. Also, recall that the boot sector is loaded in 512 B in [0x07C00, 0x07E00). We can safely use 29.75 KiB B in [0x00500, 0x07C00) and 480.5 KiB in [0x07E00, 0x80000) for a total of 510.25 KiB. There are also 128 KiB in [0x80000, 0xA0000), but some systems consume part of this region for the extended BIOS data area.
In my boot sector implementation, I use 5 B in [0x07E00, 0x07E05) to store the number of sectors on the disk and the logical block address of the root directory and IO.SYS. I use 29.75 KiB in [0x00500, 0x07C00) for the root directory index. Each root directory entry is 32 B, meaning that IO.SYS must be one of the first 952 entries. (MS-DOS 4.0 expects IO.SYS to be the first record in the root directory.) Here is a table describing the layout of each root directory entry:
| Length | Name |
|---|---|
| 8 | Filename |
| 3 | Extension |
| 1 | Attributes |
| 1 | Reserved |
| 1 | Creation time (microseconds portion) |
| 2 | Creation time |
| 2 | Creation date |
| 2 | Last access date |
| 2 | Reserved |
| 2 | Last modified time |
| 2 | Last modified date |
| 2 | Cluster offset |
| 4 | File size in bytes |
Dates are 16-bit, little-endian values stored in the following format: YYYYYYYMMMMDDDDD. Timestamps are 16-bit, little-endian values stored in the following format: HHHHHMMMMMMSSSSS.
Once IO.SYS is found, I store its first 3 sectors at 0x00700. I expect these 3 sectors to be unfragmented. This leaves 512 B in [0x00500, 0x00700) free for IO.SYS to store a copy of the boot sector later on.
Compiling the code into a raw binary with 16-bit opcodes became my next challenge. I was pleased to find that this is possible with GCC and binutils with a little bit of magic. First, I had to add the .code16gcc assembler directive to my C code. I also had to create a custom linker script to create a raw binary with a boot signature. The script instructs ld to construct a binary with a code segment, read-only data segment, and a boot signature. It also sets the instruction pointer to the correct memory offset.
The source code is released under the MIT license and is also available on GitHub at github.com/kjiwa/x86-boot-sector-c.
/*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
// MS-DOS boot sector: loads IO.SYS from the root directory and executes it.
#ifndef __GNUC__
#error "This code requires GCC with inline assembly support"
#endif
asm(".code16gcc");
// Boot sector layout: 3-byte JMP, 59-byte BPB, then code.
asm("jmp _start");
asm(".space 0x003b");
typedef char int8_t;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned long uint32_t;
// FAT12/16 boot sector structure matching the on-disk layout.
typedef struct __attribute__((packed)) {
int8_t _a[3];
int8_t name[8];
uint16_t bytes_per_sector;
uint8_t sectors_per_cluster;
uint16_t reserved_sectors;
uint8_t fats;
uint16_t root_entries;
uint16_t total_sectors;
uint8_t media_descriptor;
uint16_t sectors_per_fat;
uint16_t sectors_per_track;
uint16_t heads;
uint32_t hidden_sectors;
uint32_t total_sectors2;
uint8_t drive_index;
uint8_t _b;
uint8_t signature;
uint32_t id;
int8_t label[11];
int8_t type[8];
uint8_t _c[448];
uint16_t sig;
} boot_t;
// Track current disk operation: LBA address and sectors per track (from BIOS).
typedef struct {
uint8_t sectors;
uint32_t lba;
} disk_t;
// FAT directory entry: 8.3 filename format plus metadata.
typedef struct __attribute__((packed)) {
int8_t filename[8];
int8_t extension[3];
uint8_t attributes;
uint8_t _a;
uint8_t create_time_us;
uint16_t create_time;
uint16_t create_date;
uint16_t last_access_date;
uint8_t _b[2];
uint16_t last_modified_time;
uint16_t last_modified_date;
uint16_t cluster;
uint32_t size;
} entry_t;
#define BOOT_SECTOR_ADDR 0x7c00
#define DISK_INFO_ADDR 0x7e00
#define ROOT_DIR_ADDR 0x0500
#define IO_SYS_LOAD_ADDR 0x0700
#define FIRST_HDD 0x80
#define IO_SYS_SECTORS 3
#define FILENAME_EXT_LEN 11
// BIOS loads the boot sector here during POST.
boot_t const *boot_sector = (boot_t *)BOOT_SECTOR_ADDR;
// Store disk geometry info right after boot sector in free memory.
disk_t *disk_info = (disk_t *)DISK_INFO_ADDR;
// IO.SYS in 8.3 format: "IO " + "SYS"
int8_t const *io_sys_name = "IO SYS";
// Multi-purpose buffer: first for root directory, then for IO.SYS itself.
uint8_t *buffer;
// Number of sectors to read in the next disk operation.
uint8_t sector_count;
// Current directory entry being examined during search.
entry_t const *current_entry;
// Compare directory entry name against "IO SYS" (8.3 filename format).
// Standard string comparison: checks until mismatch, null terminator, or end of
// string.
int8_t is_io_sys(void) {
uint16_t i;
for (i = 0; i < FILENAME_EXT_LEN - 1 && ((int8_t *)current_entry)[i] &&
((int8_t *)current_entry)[i] == io_sys_name[i];
++i)
;
return ((int8_t *)current_entry)[i] - io_sys_name[i];
}
// Read sectors from disk using BIOS INT 13h, AH=02h.
// Converts LBA to CHS since older BIOS doesn't support LBA addressing.
void read_sectors(void) {
uint32_t sectors_per_cylinder = boot_sector->heads * disk_info->sectors;
uint16_t cylinder = disk_info->lba / sectors_per_cylinder;
uint16_t head = (disk_info->lba % sectors_per_cylinder) / disk_info->sectors;
// Pack cylinder (10 bits) and sector (6 bits) into CX register format.
cylinder <<= 8;
cylinder |=
((disk_info->lba % sectors_per_cylinder) % disk_info->sectors) + 1;
// INT 13h, AH=02h: Read sectors into memory
// AL=sector_count, ES:BX=buffer, CX=cylinder/sector, DH=head, DL=drive
asm("int $0x13"
:
: "a"(0x0200 | sector_count), "b"(buffer), "c"(cylinder),
"d"((head << 8) | FIRST_HDD));
}
uint16_t _start(void) {
// Get disk geometry using INT 13h, AH=08h.
// CL bits 0-5 contain sectors per track.
asm("int $0x13"
: "=c"(disk_info->sectors)
: "a"(0x0800), "d"(FIRST_HDD)
: "bx");
disk_info->sectors &= 0b00111111;
// Calculate root directory location: after reserved sectors and both FATs.
buffer = (uint8_t *)ROOT_DIR_ADDR;
disk_info->lba = boot_sector->reserved_sectors +
(boot_sector->fats * boot_sector->sectors_per_fat);
sector_count = boot_sector->root_entries * sizeof(entry_t) /
boot_sector->bytes_per_sector;
read_sectors();
// Scan root directory entries for IO.SYS.
for (current_entry = (entry_t *)buffer;; ++current_entry)
if (is_io_sys() == 0) {
// Found IO.SYS. Calculate its location in the data area.
// FAT cluster numbering starts at 2, so offset = (cluster - 2) *
// sectors_per_cluster.
buffer = (uint8_t *)IO_SYS_LOAD_ADDR;
disk_info->lba += sector_count + (current_entry->cluster - 2) *
boot_sector->sectors_per_cluster;
sector_count = IO_SYS_SECTORS;
read_sectors();
// Transfer control to IO.SYS at 0000:0700
asm("jmpw %0, %1" : : "g"(0x0000), "g"(IO_SYS_LOAD_ADDR));
}
return 0;
}