The Mkos Boot Loader

After doing some initial planning, my first task in creating this OS was to write a boot loader. Generally, a boot loader is a program that boots (starts) a computer. It does whatever initialization tasks are necessary before loading the user’s desired program. Usually, that program is an operating system, but not necessarily so. A boot loader can transfer control to a single program written especially for the hardware with no OS to manage it. Or the boot loader itself can be the user’s program.

In BIOS parlance, the first sector of a data on a medium such as a floppy or hard disk is called the boot sector. In practice, all floppy disks have a sector size of 512 bytes. When the BIOS boots from a storage device, it loads the boot sector into the computer’s memory at location 0x7c00. Then it jumps to location 0x7c00 and executes whatever code is there. The code within the boot sector is called the boot loader. The fact that the BIOS loads only 512 bytes is what distinguishes the boot loader program from whatever other software might be running at the end of the boot sequence; whatever the boot loader must do, it must do within that size limitation.

The last two bytes of the boot sector must be 0xaa55. This is the bootable partition signature, which the BIOS may use to verify the boot sector is actually boot loader code. That gives the programmer 512 - 2 = 510 bytes for boot loader code.

In practice, the choice of file system may reduce the available space. I chose FAT12 as the file system for Mkos. FAT12 is a simple file system that can be read and written by tools in many operating systems. The FAT12 file system reserves the first 62 bytes of the disk, which is also the first 62 bytes of the boot sector. This reservation is used for the BIOS Parameter Block (BPB) and the extended boot record. These data structures carry the information that the code reading from the storage device needs to understand the file system and transfer files to/from the device. The BIOS loads them into the boot loader area in front of the boot loader code. Then the boot medium has a FAT12 file system, 510 - 62 = 448 bytes of space left for boot loader code.

These are the requirements for the Mkos boot loader:

  • Initialize the CPU and system hardware.

  • Load the entire OS kernel into memory.

  • Transfer control to the kernel.

Initialize the CPU and system hardware

Initialize the data segment

Again, the boot loader area starts at 0000:7c00. The boot loader code refers to some data in the boot loader area, so it sets the DS register to segment 0.

Intialize the stack

Both the boot loader and kernel require a working stack. The boot loader needs one since it uses a few subroutines. The Mkos kernel is written in C. wcc does not emit code to initialize the stack, so the boot loader must do it on the kernel’s behalf before executing it.

Once the boot loader is finished loading the kernel, it transfers control to the kernel and never runs again. At this point, the boot loader leaves the stack empty for the kernel’s use. So rather than configure a separate stack for the boot loader, we configure the kernel’s stack and let the boot loader share it temporarily.

mov ax,kernel_stack_segment  ; This constant defined elsewhere.
mov ss,ax                    ; ss = kernel_stack_segment
mov ax,kernel_stack_pointer  ; This constant defined elsewhere.
mov sp,ax                    ; sp = kernel_stack_segment

Initialize the floppy drive

The boot loader calls BIOS int 0x13, 0 to initialize the floppy drive. This is required before reading from the floppy.

Load the kernel into memory

Mkos tries to do things as simply as possible at this stage of development. As I was experimenting with mkfs.fat and mtools for creating FAT disk images, I noticed that the FAT data, which is the part of the image where file contents begin, is always at the same offset in the disk image. I also noticed that mtools always places the first file copied to the image at the start of the FAT data area. If the build system copies the kernel image to the disk image first, then the kernel is always located at the same offset in the disk image. The FAT data area starts at 0x4200 in the disk image. The start of the kernel is at 0x4200 bytes / 512 sectors / bytes = sector 33.

Mkos uses BIOS interrupt calls for its I/O functions. The BIOS call to read a sector from a disk is int 0x13, 2. When we refer to the location above as “sector 33”, we’re describing a linear offset from the start of the disk image. BIOS disk calls, however, use a cylinder/head/sector (C/H/S) addressing scheme, which is a reference to the physical characteristics of floppy and hard disks and drives.

  • On a disk medium, a track represents a circle of some radius on one side of the disk platter.

  • A cylinder is a group of tracks with the same radius.

  • A track is divided into sectors.

  • A drive head can read all the tracks on one side of the medium. All the drive heads can read all the tracks in a cylinder from a stationery position as the medium rotates.

  • A 1.44 MiB floppy is 18 sectors / track * 80 tracks / side * 2 sides = 2880 sectors.

To convert a linear sector index in a disk image to C/H/S, we must know that data on a floppy is organized first by sector (track position), then by track, then by head (side of medium), then by cylinder (head position on radius of medium). And the final piece in this puzzle is that track and head numbers start at 0, and sector numbers start at 1. We can now write a function to convert a linear sector index to C/H/S numbers.

cylinder = sector_index / 36;
sector = sector_index % 36;

if (sector > 17) {
    head = 1;
    sector -= 17;
} else {
    head = 0;
    sector += 1;
}

Applying this function to sector 33, we get:

cylinder = 33 / 36 = 0
sector = 33 % 36 = 33
// sector > 17
head = 1
sector -= 17 == 16

The last bit of information needed make a BIOS read call is the number of sectors to read. At first, I just YOLOd it and used a number of sectors safely greater than the size of the kernel:

mov ch,0     ; Cylinder
mov dh,1     ; Head
mov cl,16    ; Sector 33, starting at 0x4200
mov al,40    ; Number of sectors to read.
mov dl,0     ; Floppy drive 0.
mov ah,2     ; Function: read disk sector.
int 0x13

Specifying an arbitrary number of sectors for the read operation is not supported by int 0x13, 2 or real floppy controllers. I knew this would likely cause problems when it came to testing on real hardware. The bochs BIOS is generous, though, so it was fine as an experimental hack that would reliably load the kernel into memory.

The bigger immediate problem was that the kernel could grow larger than the number of sectors read by the boot loader. If I didn’t remember to increase the value, the kernel would load incompletely. The next task was to make the boot loader load the exact number of sectors occupied by the kernel.

The boot loader would have to know the exact size of the kernel. Because the FAT12 directory includes the file sizes for all the files on the image, my original idea was to parse the FAT directory for the information. Ultimately, I decided instead to put the size of the kernel in sectors into the boot loader area itself. This would be done at the end of the OS image build process. After the build system created the floppy image with FAT12 file system and copied all OS files to it, it would patch the kernel size into the boot loader area at the last byte before the boot signature. Then the assembly code to load the kernel could use that value directly.

  mov ch,0                        ; Cylinder
  mov dh,1                        ; Head
  mov cl,16                       ; Sector 33, starting at 0x4200
  mov al,[kernel_size_sectors]    ; Number of sectors to read.
  mov dl,0                        ; Floppy drive 0.
  mov ah,2                        ; Function: read disk sector.
  int 0x13

...

kernel_size_sectors: db 0    ; This value is patched at build time.
dw 0xaa55                    ; Boot Signature

With the kernel size issue solved, I could continue development on the rest of the system. The boot loader stayed this way until phase 1 development was nearing its end and it was time to start testing on real hardware.

When I did the first real hardware tests, it was time to revisit the kernel loading code again.

The int 0x13, 2 call above clearly reads across a cylinder boundary. Reading across a cylinder boundary isn’t necessarily supported by either floppy drive controllers or BIOS. Out of curiosity, I tested the image without changes on my P100 machine. Unsurprisingly, it failed with error code 4 (sector not found).

While reading across a cylinder boundary isn’t supported, most drive controllers and BIOSes should support reading all sectors from both heads’ tracks with a single call. If the starting head is 0 and the number of sectors to read would exceed the last sector on the track, the system should switch to head 1 and keep reading from sector 1 on its track. This is called multi-track mode. But the simplest and safest way to read multiple sectors seems to be to read them one at a time, calculating C/H/S and making a BIOS call for each sector. That’s what I decided to do.

int 0x13, 2 calls on floppy drives are subject to read errors caused by motor failing to start quickly enough. Multiple BIOS sources recommend retrying reads at least 3 times, resetting the disk controller between tries. As I was changing the boot loader code to load the kernel sector by sector, I also added code to retry the read before failing with an error message. The current kernel loading routine in the boot loader is similar to this example:

  ; Load kernel into memory.

  mov cx,0    ; Count of kernel sectors loaded.
  mov bx,0    ; Initial offset to load kernel.

.l1:
  push es

  mov ax,kernel_segment    ; Kernel segment, defined elsewhere.
  mov es,ax

  mov ax,cx
  add ax,33     ; First kernel sector.

  call load_sector
  pop es
  cmp ah,0
  jne error     ; Result of load_sector was not successful.
                ; Jump to fatal error handler, defined elsewhere.

  add bx,512    ; Add sector size to destination offset.
  inc cl
  cmp cl,[kernel_size_sectors]
  jnz .l1

  jmp kernel_start    ; Kernel start routine, defined elsewhere.

; input: al is the sector index, es:bx = pointer to destination
; output: ah is the status code from int 0x13
load_sector:
  push cx
  push dx
  push bx
  push bp

  mov bp,sp
  sub sp,3
  mov [bp-2],bx         ; Store destination
  mov byte [bp-3],3     ; Store initial retry count. 3 retries.

  call sector_to_chs    ; output: ah = sector, al = cylinder, bl = head

  mov cl,ah     ; Sector
  mov ch,al     ; Cylinder
  mov dh,bl     ; Head
  mov dl,0      ; Floppy drive 0.
  mov ah,READ   ; Function: read disk sector
  mov al,1      ; Number of sectors
  mov bx,[bp-2] ; Destination
.read:
  int 0x13
  cmp ah,0
  jz .exit
  dec byte [bp-3]
  cmp byte [bp-3],0
  jz .exit
.retry:
  call floppy_reset
  mov ah,READ
  jmp .read
.exit:
  add sp,3

  pop bp
  pop bx
  pop dx
  pop cx

  ret

; input: ax is the sector index
; output: ah = sector, al = cylinder, bl = head
sector_to_chs:
  xor ah,ah ; only support 8-bit sector values
  mov bl,36
  div bl    ; ah = sector (modulus), al = cylinder (quotient)
  cmp ah,17
  jg .l0
  ; sector <= 17
  mov bl,0
  inc ah
  jmp .l1
.l0:
  ; sector > 17
  mov bl,1
  sub ah,17
.l1:
  ret

Transfer control to the kernel

After the boot loader loads the entire kernel into memory, its last task is to jump to the kernel entry point. This is where the kernel takes control and the boot loader’s job is done.

Generally, the entry point of a program is the memory location where it should start. In the C language, the entry point is defined as the function called “main”. Since the kernel is written in C, the boot loader must know and jump to the memory location of its “main” function.

When assembling and linking programs by hand, the programmer can control the entry point location. But we’re using wlink to assemble the kernel with its dependencies such as the FAT driver and the C standard library. A linker program makes its own decisions about where to place code; that’s part of its job. So the entry point location may not be directly controlled, and may change between linker invocations as the input modules change.

Fortunately, wlink can create a map file with the results of the linking process. This map file contains an entry with the location of the entry point:

Entry point address: 1118:0a59

We reserve some space at the beginning of the kernel image to hold this value. After building the kernel, the build system reads its entry point from the map file and writes it into the first 4 bytes of the kernel image.

Assuming the entry point address is that above and the kernel is loaded at the base of segment 0x1000, the memory might look like this:

00010000  59 0a 18 11 00 00 00 00  00 00 00 00 00 00 00 00

The boot loader can now execute the kernel using an indirect jump.

mov ax,0x1000
mov ds,ax                ; ds == kernel segment 0x1000
jmp far [0]              ; Jump to the entry point through pointer.
                         ; The contents of kernel segment:offset 0 are
                         ; the absolute address of the entry point.

References

https://wiki.osdev.org/Bootloader https://en.wikipedia.org/wiki/Bootloader https://gist.github.com/XlogicX/8204cf17c432cc2b968d138eb639494e http://www.brokenthorn.com/Resources/OSDevIndex.html