Custom Operating System Series
Part Three : Creating a BIOS-compatible Boot Sector
This article is part 3 in a series introducing system software concepts for Intel x86 or compatible processors in protected mode.
In this series we are developing a simple protected mode operating system in assembly language.
We are using the Netwide Assembler (NASM) to assemble our code and VMware Player as our test platform.
In our first article, we downloaded, installed and configured VMware Player to launch a virtual machine that boots from a virtual floppy disk image file (os.flp).
In our second article, we created a simple program that called the BIOS to display a text message and assembled that program code into the floppy disk image to demonstrate how VMware will load and run code from the first sector of the virtual floppy disk device.
In this article, we will return to our program and extend the code to accomplish two goals.
First, we will make our program a BIOS-compatible boot sector, containing a disk parameter table and a valid signature field.
Secondly, we will add code to the boot sector to read the disk directory searching for an operating system program, load that program into memory and transfer control to it.
Step 1. Adding the Disk Parameter Table
In our previous article, we saw that VMware writes several log messages when it detects a boot sector program that does not contain a valid disk parameter table.
In that situation VMware makes assumptions about the disk geometry based on the size of the floppy disk image file.
We want to provide the BIOS a valid disk parameter table.
The disk parameter table must reside at the start of our boot sector code, beginning with the fourth byte - in other words, immediately after a three-byte JMP instruction.
Here is the start of our new boot sector program that shows the initial JMP and the disk parameter table.
We've defined five new symbolic constants, "EMAXTRIES", "EFATBUFFER", "EBIOSKEYBOARDINT", "EBIOSWAITFORKEYFN" and "EKEYPORTSTAT".
We'll discuss these in more detail below.
Note that at line 68 we have added a "section" statement, defining a "boot" section with a virtual start address ("vstart=") of 100h.
This instructs NASM to assemble label offset addresses assuming an initial offset of 100h at the start of the section.
This is not required for boot sectors.
We do this so that if we want to debug our boot sector code in a DOS environment, we can save the boot sector as a .COM file and use "debug" to step through the code.
A JMP instruction is coded at line 70 to bypass the disk parameters table.
Using NASM, we can direct the assembler to generate a WORD-sized relative address operand to our JMP instruction
so that our disk parameters table will start at the correct position.
The disk parameters table begins with an eight-byte label that can be whatever we choose.
I've chosen "CustomOS" to avoid confusing our boot sector with anyone else's.
The comments provided in the source code describe each field in the disk parameter table.
Step 2. Adding Logic to Normalize DS and ES values
In our previous article, we avoided defining data labels because we did not know what our caller had set our CS:IP registers to.
Now we will introduce code that will normalize our DS and ES registers regardless of what CS:IP are set to on entry.
We can then use "vstart=100h" to generate data label offsets compatible with .COM files no matter what CS:IP is set to on entry.
Here is the code to do that:
The label "Boot.10" is where our initial JMP instruction brought us.
We then issue a call to the next sequential instruction (at label .20) followed by a "pop ax".
These instructions have the effect of storing in AX the value of IP at the label ".20".
Note in the comments that we track the values in our work registers in three columns,
each column corresponding to different possible initial values in CS:IP when our program was called.
The comments show that by the time our program completes line 97, we have a value in BX that is identical, no matter what CS:IP originally were.
This value corresponds to what our CS would be if our section's vstart parameters had been zero.
Since we want our data labels to conform to a .COM-style program, however, we set BX back by one paragraph (16 bytes), the size of a Program Segment Prefix (PSP), before assigning its value to DS and ES.
At this point, our assembled data references which relied on our vstart=100h statement will correspond to our DS and ES segment register values.
Note that this code works so long as the BIOS loads the boot sector at a paragraph boundary.
Step 3. Validate the Assembled Data Addresses
We want to validate that our addressing fixup logic has worked properly.
To do this, we can now add logic to load the address of a message into SI and call our "BootPrint" subroutine, which is similar to "PutTTYString" introduced in our last article.
Except now we will use a MOV instruction to load SI, which will populate SI with a value that was computed by NASM at assembly time.
Here is the code that calls our "BootPrint" subroutine as it appears in the assembly listing.
Here is what our assembled binary program looks like using HxD, our hex editor.
I've highlighted the MOV instruction to show how NASM has resolved the relative address of our message, 0x1B7, to the correct offset 0x2B7, given our vstart=100h.
Step 4. Adding Logic to Search the Disk Directory
The next section of our program prepares a set of variables that we will use to loop through each directory sector on the disk and search each of those sectors for a directory entry that has a name that matches our operating system file, "os.com".
First, we zero AL and store AL to the variable "Drive" since all disk i/o will be to/from our floppy drive.
Note that NASM expects braces around the name of a variable when the content of that variable is being accessed or updated.
Next we compute an address of memory where directory sectors will be read into, "DirBuffer".
Our boot sector actually has two file i/o locations, "DirBuffer" and "EFATBUFFER".
We saw "EFATBUFFER" defined at the start of our program as a constant using the equate ("equ") statement with the value of 0400h.
Our coding conventions capitalize symbolic constant names to make it easier to distinguish them from variables.
We will read one entire copy of the file allocation table (FAT) into EFATBUFFER at address 0400h, relative to DS.
We will read one directory sector at a time into dirbuffer immediately following the FAT.
In order to know where our directory buffer will be, we compute the size of one copy of the FAT.
This is done by multiplying the number of FAT sectors by the number of bytes in a disk sector.
Both of these values are taken from the disk parameter table.
Next we compute a segment address for the program code we will load and run.
We will overwrite our directory buffer with the program code that we load from disk.
Since the program we will load from disk will also be assembled with a "vstart=100h" statement in its entry section, the segment value used for its entry address will correspond to where a .COM program's PSP would be.
Next we initialize a count of how many directory entries remain to be searched for our operating system program.
We set this value, "EntriesLeft", to the total number of directory entries on this, "DirEntries", which is found in the disk parameters table.
Next we need to compute two values, a count of "Overhead" sectors and the number of directory entries per sector.
The "Overhead" sectors are simply the number of sectors at the start of a disk that do not contain the contents of files on the disk.
This includes the boot sector, FAT and directory sectors and any reserved or special sectors.
The "SectorEntries", or total directory entries divided by directory sectors, tell our program how many entries must be search on each sector.
Step 5. Adding Logic to Load the Operating System Program from Disk
Now we enter a loop which reads each directory sector and searches each sector for our operating system program "os.com".
The label ".30" is the top of our outer loop which iterates once per directory sector.
We call a subroutine, "ReadSector" to read one sector into the buffer address stored in "DirBuffer".
Note that if a disk i/o error occurs, "ReadSector" will not return.
After returning from "ReadSector", we load CX with either the number of entries in a sector or the number of entries left to search, whichever is less.
We then update the remaining sector count and initialize SI and DI for our entry search.
DS:SI points to the name of the program we are searching for.
DS:DI points to the directory entry.
The label ".50" is the top of our inner loop which iterates once per directory sector entry.
If our comparison, using the CMPSB instruction, finds a match, we jump to ".60".
Otherwise, we move DI to the next directory entry and move to our next inner loop iteration.
After searching each entry in the sector, we increment our "LogicalSector" number.
If we have more entries to search, we move to our next outer loop iteration.
Otherwise, we have not found the program on the disk.
So, we put the address of a "NoKernel" message in SI and jump to the label "BootExit".
Now that we have found our operating system file, we must read the file into memory.
To do that we must traverse the file allocation table (FAT) starting with the first cluster containing our program code.
The first cluster is stored in the directory entry for the program.
The next section of our boot sector code traverses the FAT, loading each cluster into memory.
The first part of this code reads a copy of the FAT and sets up variables for our loop.
We set "LogicalSector" to the first sector occupied by the FAT.
We set "ReadCount" to the number of sectors in one copy of the FAT.
We point BX to the address where we want to load the FAT and we call "ReadSector".
After reading the FAT, we set AX to the starting cluster of the program we want to load and set ES:BX to the address where we want to load the program.
Now we can read each cluster of our operating system program into memory.
At the top of our loop, ".70", we save the current cluster number, set the number of sectors to read into "ReadCount", convert the current cluster to a logical sector number in "LogicalSector" and call "ReadSector".
In our case, each cluster contains only one sector, but that is determined by the disk parameter table field "ClusterSectors" and varies by disk type.
When converting cluster to sector, remember to add the "Overhead" sectors to arrive at the correct sector number.
After returning from "ReadSector", we update BX to point to where we want to load the next cluster.
Computing the next cluster number is a bit involved because of how FAT-12 cluster numbers are represented in the FAT.
Each cluster is represented by three nybbles in the FAT. But, the encoding differs for even versus odd clusters.
If we find a FAT entry with the value "0xFFF", we have processed the last cluster holding part of our file.
Otherwise, we return to the top of our loop at ".70".
Once we have read our entire program into memory, we can transfer control to it.
This is done by a simple far jump to where we loaded our program.
Remember that even though "LoadSegment" is hard-coded here, we actually recomputed the value.
The "LoadOffset" is fixed at 0100h so that our operating system program can be assembled as though it was a .COM program.
Our boot sector code continues with the subroutine "ReadSector".
The first part of this subroutine converts "LogicalSector" into physical "Head", "Track" and "Sector" numbers.
We then enter a loop which attempts to read the sectors upto "EMAXTRIES" times.
If we fail to read the sectors after "EMAXTRIES" attempts, we fall into our disk error handling code.
Here, we format our disk error code, found in AH, into our ASCII error message at "ErrorMsg".
Then we call our "BootPrint" subroutine to display the error code in a formatted string.
After displaying the string, we make a call to the BIOS using interrupt 22 (0x16) using function 0.
This will cause the BIOS to wait until a key is pressed before returning to our program.
When a key is pressed, we send the value 0xFE to I/O port 0x64.
This drives pin 0 of the 8042 keyboard controller to low, triggering a hardware restart.
We then enter a HLT loop until the hardware restart kicks in.
The remainder of our boot sector program contains our data fields.
We supply contants first and then leave room for variables.
The one other problem that we saw VMware describe in its log in our last article was the absence of a valid "signature" in our boot sector.
The signature is simple two-byte value (0x55 0xAA) in the last two bytes of the boot sector.
Since we have decided to use a floppy disk image that has 512-byte sectors,
we need to have NASM put the values 0x55 and 0xAA at offets 510 and 511, respectively.
Source code lines 351 and 352 accomplishe this.
At the end of our loader variables, we use the NASM "times" statement to insert just enough null bytes to bring our ouput up to the last two bytes of the sector.
Then we define a word value, 0xaa55h, to place the 0x55 at offset 1FE and 0xaa at offset 1FF.
In order to complete the "times" statement, we need to use a syntax that represents the current output address relative to the start of our section.
Then we subtract that from 510 to determine how many bytes of filler to generate.
We then generate that many bytes by ending our expression with "db 0".
Interpret the "($-$$)" as measuring the number of bytes from "here" to the start of the current section.
Remember to check this part of your assembly listing after making changes to your boot sector code
to make sure your boot sector logic still fits within the size of one sector.
Here is the complete boot sector as viewed in our hex editor.
Step 6. Adding File Allocation Tables and a Directory
Our boot sector is complete.
But, we do not yet have a valid FAT or directory on our disk image file, os.flp.
For this article, we will extend our program to define a simple FAT and a single directory entry.
Note that BIOS expects there to be two copies of the FAT followed by the directory.
Just before the start of the first FAT, we introduce a conditional compilation directive, "%ifdef BUILDDISK".
This instructs NASM to only assemble the code that follows as part of os.flp if the BUILDDISK directive is defined.
When we assemble os.flp, we include "-DBUILDDISK" to pass this directory to NASM.
The first FAT copy follows immediately after the boot sector.
Since our operating system program that we are loading will fit into one cluster, the FAT entry is simple.
Note that we define a second copy of the FAT which immedately follows the first.
According to our disk parameter table field, "FatSectors", each FAT copy occpies nine sectors.
After our second FAT copy is our directory.
We have only one 32-byte entry, for our "os.com" program.
Step 7. Creating a Simple Operating System Program
Lastly, we need an actual program for the boot sector to load and run.
Again, we will start with a very small program that simply displays a message to the screen, waits for a keypress and then restarts the virtual machine.
Here is the source code for that program which we will expand in later articles.
If the BUILDDISK directive is included, we want NASM to construct an entire disk image file.
The last few lines of our program generates a block of default or unused (0xF6) bytes enough to fill the entire disk image file.
Now, when VMware loads our boot sector and calls it, the program searches our disk image file for a directory entry for our operating system file.
Finding that, our boot sector loads the operating system file into memory and calls it.
Our operating system program displays its own message to the screen.
We can confirm the operation of our program by noting the messages displayed.
The "Loading ..." message, as we saw in our last article, is generated by the initial loader code.
The "Starting ..." message, introduced in this article, is generated by the os.com program that was loaded and run by our loader.
We have successfully assembled a complete boot sector program that uses BIOS functions to read the disk to locate an operating system file, read the operating system file into memory and transfer control to that program.
We have also introduced an assembler directive to include the generation of code to provide the required file allocation table, directory entries and the initial operating system loader, os.com.
In the next article, we will expand our program to include 32-bit protected-mode operating system kernel, enter protected mode on startup and transfer control to the 32-bit kernel.
Here is a link to the entire listing of the boot sector program from this lab.
Revised 10 October 2014