Search code examples
assemblynasmx86-16bootloaderfat16

FAT16 Bootloader only Loading First Cluster of a File


I am currently in the process of fixing a bootloader I wrote to load my custom real-mode x86 kernel (SYS.BIN). I managed to get it to read the root directory and FAT, and load a small-ish kernel from the filesystem, all within the bootsector. However, I began testing it with larger kernels, and it seems that the bootloader will not load more than one cluster. I checked my code against another similar bootloader, and it seems to be doing effectively the same thing when it comes to loading multi-cluster files. The main difference is that I am loading the first FAT into segment 0x3000 and the root directory into segment 0x3800, so that they will be accessible to the kernel. (Did I mess up segmentation at all?)

I should probably mention that I'm testing this by compiling with NASM, writing the resulting BOOT.BIN file to the first sector of a raw 32M image, mounting it on a loop device, copying SYS.BIN over, and creating a new image of that loop device, which I then throw into QEMU as a hard drive. I know with certainty that it is only loading the first cluster of the file.

In particular, I believe the code that is causing the issue is likely in here:

.load_cluster:
    mov si, msg_load_cluster
    call print_str                      ; Print message

    mov ax, word [cluster]              ; Our cluster number
    sub ax, 0x0002                      ; Clusters begin at #2
    mul byte [sectors_cluster]          ; Multiply by number of sectors
    mov dx, ax                          ; Save in DX

    call calc_root_start                ; Start of root directory
    add ax, 0x20                        ; Root directory is 32 sectors
    add ax, dx                          ; Add to the number of sectors

    call calc_chs_ls                    ; Convert this Logical sector to CHS

    mov ax, 0x2000
    mov es, ax                          ; Load the kernel into this segment
    mov bx, word [buffer_pointer]       ; At this offset
    mov ah, 0x02                        ; Read disk sectors
    mov al, byte [sectors_cluster]      ; 1 cluster

    int 0x13                            ; BIOS disk interrupt
    jnc .next_cluster                   ; If no error, set up for the next cluster

    call reset_disk                     ; Otherwise, reset the disk

    mov ah, 0x02                        ; Read disk sectors
    mov al, byte [sectors_cluster]      ; 1 cluster
    int 0x13                            ; Try again
    jc reboot                           ; If failed again, reboot

.next_cluster:
    mov ax, 0x3000
    mov ds, ax                          ; Segment where the FAT is loaded

    mov si, word [cluster]              ; Our cluster number
    shl si, 0x1                         ; There are two bytes per entry in FAT16

    mov ax, word [ds:si]                ; DS:SI is pointing to the FAT entry
    mov word [cluster], ax              ; The entry contains our next cluster

    cmp ax, 0xFFF8                      ; Is this the end of the file?

    mov ax, 0x0200
    mul word [sectors_cluster]
    add word [buffer_pointer], ax       ; Advance pointer by one cluster

    jb .load_cluster                    ; If not, load next cluster

And here is my full code, including the BPB:

    BITS 16

    jmp strict short main
    nop


; BIOS Parameter Block
; This was made to match up with the BPB of a blank 32M image formatted as FAT16.

OEM                 db "HDOSALPH"       ; OEM ID
bytes_sector        dw 0x0200           ; Number of bytes per sector (DO NOT CHANGE)
sectors_cluster     db 0x04             ; Number of sectors per cluster
reserved            dw 0x0001           ; Number of sectors reserved for bootsector
fats                db 0x02             ; Number of FAT copies
root_entries        dw 0x0200           ; Max number of root entries (DO NOT CHANGE)
sectors             dw 0x0000           ; Number of sectors in volume (small)
media_type          db 0xF8             ; Media descriptor
sectors_fat         dw 0x0040           ; Number of sectors per FAT
sectors_track       dw 0x0020           ; Number of sectors per Track (It's a LIE)
heads               dw 0x0040           ; Number of heads (It's a LIE)
sectors_hidden      dd 0x00000000       ; Number of hidden sectors
sectors_large       dd 0x00010000       ; Number of sectors in volume (large)
drive_num           db 0x80             ; Drive number
                    db 0x00             ; Reserved byte
extended_sig        db 0x29             ; Next three fields are available
serial              dd 0x688B221B       ; Volume serial number
label               db "NATE       "    ; Volume label
filesystem          db "FAT16   "       ; Volume filesystem type


; Main bootloader code

main:
    mov ax, 0x07C0                      ; Segment we're loaded at
    mov ds, ax
    add ax, 0x0020                      ; 32-paragraph bootloader
    mov ss, ax
    mov sp, 0x1000                      ; 4K stack

    mov byte [boot_drive_num], dl       ; Save boot drive number

    mov ah, 0x08                        ; Read disk geometry
    int 0x13                            ; BIOS disk interrupt

    mov dl, dh
    mov dh, 0x00
    inc dl
    mov word [heads], dx                ; The true number of heads

    mov ch, 0x00
    and ch, 0x3F
    mov word [sectors_track], cx        ; The true number of sectors per track

.load_fat:
    mov si, msg_load
    call print_str                      ; Print message

    mov ax, 0x3000
    mov es, ax                          ; Load FAT into this segment
    mov bx, 0x0000

    mov ax, word [reserved]             ; First sector of FAT 1
    call calc_chs_ls                    ; Convert to CHS address
    mov ax, word [sectors_fat]          ; Read the entire FAT
    mov ah, 0x02                        ; Read disk sectors

    int 0x13                            ; BIOS disk interrupt
    jnc .load_root                      ; If no error, load the root directory
    
    jmp reboot                          ; Otherwise, reboot

.load_root:
    mov si, msg_load
    call print_str                      ; Print message

    mov ax, 0x3800
    mov es, ax                          ; Load root directory into this segment

    call calc_root_start                ; First sector of root directory
    call calc_chs_ls                    ; Convert to CHS address
    mov ah, 0x02                        ; Read disk sectors
    mov al, 0x20                        ; Root directory is 32 sectors (512/512 = 1)

    int 0x13                            ; BIOS disk interrupt
    jnc .search_init                    ; If no error, begin searching

    call reset_disk                     ; Otherwise, reset the disk

    mov ah, 0x02                        ; Read disk sectors
    mov al, 0x20                        ; Root directory is 32 sectors (512/512 = 1)
    int 0x13                            ; BIOS disk interrupt
    jc reboot                           ; If error, reboot

.search_init:
    mov si, msg_search_root
    call print_str                      ; Print message

    mov ax, 0x07C0
    mov ds, ax                          ; The segment we are loaded at

    mov ax, 0x3800
    mov es, ax                          ; The segment the root directory is loaded at
    mov di, 0x0000                      ; Offset 0

    mov cx, word [root_entries]         ; Number of entries to look through

.check_entry:
    push cx                             ; Save this to stack

    mov cx, 0x000B                      ; Compare the first 11 bytes
    mov si, kern_filename               ; This should be the filename
    push di                             ; Save our location

    repe cmpsb                          ; Compare!

    pop di                              ; Restore our location
    pop cx                              ; Restore the remaining entries

    je .found_entry                     ; If the filenames are the same, we found the entry!

    add di, 0x0020                      ; Otherwise, move to next entry
    loop .check_entry                   ; And repeat

    jmp reboot_fatal                    ; If we've gone through everything, it's missing
    
.found_entry:
    mov ax, word [es:di+0x1A]
    mov word [cluster], ax              ; The starting cluster number

.load_cluster:
    mov si, msg_load_cluster
    call print_str                      ; Print message

    mov ax, word [cluster]              ; Our cluster number
    sub ax, 0x0002                      ; Clusters begin at #2
    mul byte [sectors_cluster]          ; Multiply by number of sectors
    mov dx, ax                          ; Save in DX

    call calc_root_start                ; Start of root directory
    add ax, 0x20                        ; Root directory is 32 sectors
    add ax, dx                          ; Add to the number of sectors

    call calc_chs_ls                    ; Convert this Logical sector to CHS

    mov ax, 0x2000
    mov es, ax                          ; Load the kernel into this segment
    mov bx, word [buffer_pointer]       ; At this offset
    mov ah, 0x02                        ; Read disk sectors
    mov al, byte [sectors_cluster]      ; 1 cluster

    int 0x13                            ; BIOS disk interrupt
    jnc .next_cluster                   ; If no error, set up for the next cluster

    call reset_disk                     ; Otherwise, reset the disk

    mov ah, 0x02                        ; Read disk sectors
    mov al, byte [sectors_cluster]      ; 1 cluster
    int 0x13                            ; Try again
    jc reboot                           ; If failed again, reboot

.next_cluster:
    mov ax, 0x3000
    mov ds, ax                          ; Segment where the FAT is loaded

    mov si, word [cluster]              ; Our cluster number
    shl si, 0x1                         ; There are two bytes per entry in FAT16

    mov ax, word [ds:si]                ; DS:SI is pointing to the FAT entry
    mov word [cluster], ax              ; The entry contains our next cluster

    cmp ax, 0xFFF8                      ; Is this the end of the file?

    mov ax, 0x0200
    mul word [sectors_cluster]
    add word [buffer_pointer], ax       ; Advance pointer by one cluster

    jb .load_cluster                    ; If not, load next cluster

.jump:
    mov si, msg_ready
    call print_str                      ; Otherwise, we are ready to jump!

    mov ah, 0x00                        ; Wait and read from keyboard
    int 0x16                            ; BIOS keyboard interrupt

    mov dl, byte [boot_drive_num]       ; Provide the drive number to the kernel

    jmp 0x2000:0x0000                   ; Jump!

    
; Calculation routines

calc_root_start:                        ; Calculate the first sector of the root directory
    push dx                             ; Push register states to stack

    mov ax, word [sectors_fat]          ; Start with the number of sectors per FAT
    mov dh, 0x00
    mov dl, byte [fats]
    mul dx                              ; Multiply by the number of FATs
    add ax, word [reserved]             ; Add the number of reserved sectors
    
    pop dx                              ; Restore register states
    ret                                 ; Return to caller

calc_chs_ls:                            ; Setup Cylinder-Head-Sector from LBA (AX)
    mov dx, 0x0000
    div word [sectors_track]
    mov cl, dl
    inc cl                              ; Sector number

    mov dx, 0x0000
    div word [heads]
    mov dh, dl                          ; The remainder is the head number
    mov ch, al                          ; The quotient is the cylinder number
    
    mov dl, byte [boot_drive_num]       ; Drive number
    ret                                 ; Return to caller


; Other routines

print_str:                              ; Print string in SI
    pusha                               ; Push register states to stack

    mov ax, 0x07C0
    mov ds, ax                          ; Segment in which we are loaded

    mov ah, 0x0E                        ; Teletype output
    mov bh, 0x00                        ; Page 0

.char:
    lodsb                               ; Load next character
    cmp al, 0x00                        ; Is it a NULL character?
    je .end                             ; If so, we are done

    int 0x10                            ; Otherwise, BIOS VGA interrupt
    jmp .char                           ; Repeat

.end:
    mov ah, 0x03                        ; Get cursor position
    int 0x10                            ; BIOS VGA interrupt

    mov ah, 0x02                        ; Set cursor position
    inc dh                              ; One row down
    mov dl, 0x00                        ; Far left
    int 0x10                            ; BIOS VGA interrupt

    popa                                ; Restore register states
    ret                                 ; Return to caller

reset_disk:                             ; Reset the disk
    push ax                             ; Push register states to stack

    mov si, msg_retrying
    call print_str                      ; Print message

    mov ah, 0x00                        ; Reset disk
    mov dl, byte [boot_drive_num]

    int 0x13                            ; BIOS disk interrupt
    jc reboot_fatal                     ; If there was an error, reboot
    
    pop ax                              ; Otherwise, restore register states
    ret                                 ; Return to caller

reboot_fatal:                           ; Display FATAL
    mov si, msg_fatal
    call print_str                      ; Print message

reboot:                                 ; Prompt user to press a key and reboot
    mov si, msg_reboot
    call print_str                      ; Print message

    mov si, msg_ready
    call print_str                      ; Print message

    mov ah, 0x00                        ; Wait and read from keyboard
    int 0x16                            ; BIOS keyboard interrupt

    int 0x19                            ; Reboot


; Data

data:

cluster             dw 0x0000
buffer_pointer      dw 0x0000
boot_drive_num      db 0x00

msg_retrying        db "RE", 0x00
msg_fatal           db "FATL", 0x00
msg_reboot          db "X", 0x00
msg_search_root     db "Srch", 0x00
msg_load_cluster    db "Clstr", 0x00
msg_ready           db "GO", 0x00
msg_load            db "Press a key", 0x00

kern_filename       db "SYS     BIN"


times 510-($-$$)    db 0x00             ; Pad remainder of bootsector with zeroes
boot_sig            dw 0xAA55           ; Boot signature

Thanks in advance for your help.

Update: I ran this in the BOCHS debugger, and it seems as though the program is loading the word in cluster as 0x0003 under .load_cluster, but then as 0x0000 under .next_cluster a few instructions later.


Solution

  • Your mov ax, word [ds:si] has an unneeded ds segment override.

    This is also related to your problem with the variables, the memory accesses use ds as the default segment. So after mov ax, 0x3000 \ mov ds, ax you are not accessing your original variables any longer.

    You have to reset ds to 7C0h, as your loader uses the default org 0. Your print_str function does reset ds like that. But the mov si, word [cluster] and everything between the FAT word access in .next_cluster and up to .jump uses the wrong ds. To correct this, change your code like this for example:

        mov si, word [cluster]
        shl si, 0x1
    
        push ds
        mov ax, 0x3000
        mov ds, ax
        mov ax, word [si]
        pop ds
    
        mov word [cluster], ax
    

    Another error: The jb just before .jump uses the Carry Flag. However, the flag is not retained from the cmp as you want, because add certainly (and mul possibly) overwrites the Carry Flag.

    More issues:

    • You assume the root directory size.

    • You assume the sector size. (To be fair, a lot of loaders do that.)

    • You assume that the FAT fits in 64 KiB. It can actually grow to nearly 128 KiB.

    • In mov ax, word [sectors_fat] \ mov ah, 0x02 the second write overwrites the top half of ax so this only works for FATs with at most 255 sectors.

    • You assume that you can read up to 255 sectors with one int 13h call. This can be wrong for ROM-BIOSes which, eg, do not support track-crossing read requests. This is why most loaders load one sector at a time.

    • You assume your kernel will fit in 64 KiB.

    • You assume you can load the kernel entirely as mapped from the FAT. With large cluster sizes this may be impossible, which is a reason most load protocols only load up to a certain amount of data.

    • In inc dl \ mov word [heads], dx you should use inc dx.

    • In and ch, 0x3F \ mov word [sectors_track], cx you meant to use and cl, 3Fh.

    • In mul dx \ add ax, word [reserved] you should add adc dx, 0.

    • You are using 16-bit sector numbers more generally. This is okay for a toy example. Modern loaders use 32-bit sector numbers.

    • You are using the CHS disk read interface (normal int 13h). There is also the LBA interface (int 13h extensions). It is only required if the CHS geometry is unknown or works out to too small a range of accessible sectors.

    • You are using int 13h ah=08h to retrieve the CHS geometry. This call is not supported for all disks or ROM-BIOSes. (Especially diskettes may not support it, but those also use FAT12 as their file system which you don't support.)

    • You are not using the hidden sectors, though in your BPB this is zero anyway. If you want to load from a partition (in an MBR-partitioned scheme) you will need to add them to get the unit sector numbers from those relative to the file system.