FAT filesystem track

Filesystem

How LainDOS turns a FAT12 or FAT16 image into DOS files: BPB validation, cluster math, directory traversal, path parsing, read caching, writes, rollback, and durable flushes.

The FAT path

LainDOS keeps the filesystem deliberately small: one active volume at a time, 8.3 names, FAT12 and FAT16 cluster chains, fixed low-memory buffers, and direct directory-entry writes. The tests around this code inspect serial output and disk images because persistence bugs are often invisible until QEMU exits.

01
Mount
The boot BPBBPBThe FAT boot-sector table that describes sector size, FAT location, root directory size, and cluster layout. is validated, FAT type is selected, and root/data/FAT regions are derived once per active drive.
02
Resolve
Paths are drive-aware, case-folded into 8.3 names, and walked from the current directory or root.
03
Locate
Directory scans read root entries from ROOT_SEG and FAT subdirectories through a bounded sector cache copied into SEC_BUF while following FAT chains.
04
Transfer
Cluster numbers become LBAs, sector I/O retries BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. calls, and sequential reads use READ_CACHE_BUF read-ahead.
05
Commit
Creates, truncates, deletes, renames, and directory growth flush directory slots and FAT copies in order.

Mount derives the FAT geometry

BPB fields become the working root, data, and cluster limits.

LainDOS rejects BPBs that would make later math ambiguous: zero sectors-per-cluster, non-power-of-two cluster sizes, missing reserved/FAT/root geometry, invalid root-entry alignment, and total sectors that cannot cover the data region.

The mount path computes kfat_start, krsta, krsc, kdsta, and the data-area cluster count, then picks FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. or FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. from the cluster count itself (fewer than 4085 clusters means FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path.) — the BPBBPBThe FAT boot-sector table that describes sector size, FAT location, root directory size, and cluster layout. type string is ignored, matching how real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. decides. Those values are what every later FAT, directory, and disk routine trusts.

src/kernel.asmNASM · 16-bit
600
545
549
551
553
597
583
586
596
616
619
634
640
644
646
650
652
mov al, [bx+BPB_SECS_PER_CLUS]
test al, al
test al, ah
cmp word [bx+BPB_RSV_SEC_COUNT], 0
cmp byte [bx+BPB_NUM_FATS], 0
mov ax, [bx+BPB_ROOT_ENT_COUNT]
mov ax, [bx+BPB_SECS_PER_FAT]
mov [cs:kfat_start], ax
mov [cs:krsta], ax
mov [cs:krsc], ax
mov [cs:kdsta], ax
sub ax, [cs:kdsta]
div cx
mov [cs:kmax_cluster], ax
mov byte [cs:kfat_bits], 12
cmp ax, 4087
mov byte [cs:kfat_bits], 16

Tests that pin this

scripts/test_bpb_invalid.pyscripts/test_fat16.pyscripts/test_partitioned_fat16.py

Clusters become BIOS sectors

Data-cluster LBAs flow through partition offsets and CHS bounds checks.

cluster_lba is the only normal path from a FAT cluster number to a sector address. It rejects cluster zero/one and clusters at or beyond kmax_cluster, then multiplies by sectors-per-cluster and adds the data start.

Sector I/O adds the partition-relative LBA, retries INT 13hINT 13hThe BIOS disk interrupt used by boot code before the DOS filesystem is available. calls, caps multi-sector reads at track and 64 KiB DMA boundaries, invalidates filesystem sector caches on writes, and advances ES across 64 KiB wrap. Geometry errors fail closed instead of letting high-LBA tests read or write the wrong sector.

src/kernel/disk.incNASM · 16-bit
1
3
5
7
9
13
25
26
28
29
37
38
39
111
116
133
140
142
190
211
228
230
cluster_lba:
cmp ax, 2
cmp ax, [cs:kmax_cluster]
sub ax, 2
mov cl, [cs:kspc]
add ax, [cs:kdsta]
read_sector:
mov cx, 1
read_sectors:
call wf_flush_sector_cache
write_sector: PERF_INC perf_sector_writes
call subdir_cache_invalidate
mov byte [cs:kio_op], 3
setup_sector_io:
mov ax, [cs:kpart_lba]
setup_bios_chs:
cmp dx, [cs:kbio_spt]
div word [cs:kbio_spt]
cmp ax, 1024
finish_sector_io:
mov ax, es
add ax, 0x1000

Tests that pin this

scripts/test_fat16_large.pyscripts/test_fat16_seek.pyscripts/test_highdir.py

FAT chains are bounded and mirrored

FAT12 keeps the whole table resident; FAT16 caches one sector in a write-back window.

FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. entries are packed 12-bit values in FAT_SEG, so odd clusters shift by four and even clusters mask to 0FFFh. The whole FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. table fits in the resident scratch buffer, so reads and writes touch it in place and a single flush mirrors it to every copy.

FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. tables can be far larger than that buffer (an 80 KiB FAT on a 160 MB volume), so both reads and writes go through one cached sector — the write-back window. fat16_window_load keeps the requested FAT sector in FAT_SEG; if a different sector is already cached and dirty it is flushed to every FAT copy first. fat16_set patches the entry in memory and only marks the window dirty, deferring the disk write to the next eviction or to flush_fat.

This matters under real-speed hardware: the old FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. path read-modify-wrote both FAT copies on every single entry, turning a multi-megabyte file's cluster chain into tens of thousands of sector writes (the Red Alert swap-file stall). The window collapses an entire FAT sector's worth of allocations — 256 entries — into one read and one write per copy. All chain operations still sanitize impossible next-cluster values against reserved and max-cluster bounds.

src/kernel/fat.incNASM · 16-bit
33
38
76
88
111
158
161
191
245
297
310
317
318
330
379
410
411
463
fat_next:
cmp byte [cs:kfat_bits], 16
fat16_next:
call fat16_window_load ; ax = FAT-relative sector
fat16_window_load:
fat16_window_flush:
cmp byte [cs:fat16_cache_dirty], 1
call write_sector
fat_set:
fat16_set:
call fat16_window_load ; ax = FAT-relative sector
mov [es:bx], ax
mov byte [cs:fat16_cache_dirty], 1
fat_alloc_cluster: PERF_INC perf_fat_allocs
fat_free_chain:
flush_fat: PERF_INC perf_fat_flushes
cmp byte [cs:kfat_bits], 16
call write_sector

Tests that pin this

scripts/test_badfat.pyscripts/test_fat16_bounds.pyscripts/test_dirmut.py

Paths become 8.3 directory keys

Drive prefixes, relative roots, dot segments, and case folding meet in name_buf.

A path first activates the requested drive, then chooses a starting directory. Leading separators force root, drive-qualified relative paths keep that drive's current directory, and plain relative paths start from cur_dir_cluster.

The parser fills an eleven-byte DOS name with spaces, uppercases each character, moves to byte eight after a dot, and expands wildcards to question marks for find-first/find-next, including extension wildcards for a name-part * without an explicit dot. Parent resolution temporarily terminates the path at the last separator and calls the same resolver on the parent directory while preserving any explicit drive prefix.

src/kernel/path_dir.incNASM · 16-bit
1060
1062
1066
1076
1081
1084
1119
1124
1185
1201
1226
1228
1240
1861
1870
1871
1884
1904
1896
1917
1921
1943
1947
1951
1965
1996
2008
2021
resolve_path:
call activate_drive_for_path
cmp byte [ds:si], '\'
mov ax, [cs:cur_dir_cluster]
mov ax, ROOT_CLUSTER
mov ax, [cs:cur_dir_cluster]
.rp_parse_name:
mov di, name_buf
call ascii_upper
mov di, name_buf + 8
call find_in_dir
test byte [es:di+11], ATTR_DIR
call find_in_dir
parse_83name:
mov di, name_buf
mov cx, 11
call ascii_upper
cmp di, name_buf + 8
.pl_dot:
mov di, name_buf + 8
mov byte [es:di], '?'
parse_root_path:
call activate_drive_for_path
mov ax, [cs:cur_dir_cluster]
mov [cs:pr_last_sep], bx
mov word [cs:pr_dir_cluster], ROOT_CLUSTER
call resolve_path
call parse_83name

Tests that pin this

scripts/test_pathcanon.pyscripts/test_drivepath.pyscripts/test_diredge.pyscripts/test_parsefcb.pyscripts/test_findstar.py

Directories scan root and cluster chains

Root entries are resident; subdirectory entries stream through the sector buffer.

Root scans index directly into ROOT_SEG, derive the backing LBA from krsta, and stop on the DOS zero entry. Subdirectory scans read each cluster sector through the subdirectory sector cache into SEC_BUF, skip deleted and volume entries, compare against name_buf, then follow fat_next to the next directory cluster.

When a subdirectory is full, find_dir_free extends it by allocating a new cluster, linking the old tail to it, zeroing every new sector, and flushing the FAT. If zeroing or flushing fails, it rolls the chain back before returning failure.

src/kernel/path_dir.incNASM · 16-bit
1270
1288
1291
1294
1475
1305
1307
1318
1343
1346
1377
1383
1397
1488
1511
1516
1519
1532
1542
1546
1578
1585
1597
1600
1604
find_in_dir:
mov ax, [cs:fid_cluster]
mov ax, ROOT_SEG
cmp cx, [cs:kroot_entries]
add ax, [cs:krsta]
cmp byte [es:di], 0
cmp byte [es:di], 0xE5
call name_matches
LOAD_SUBDIR_SECTOR rid_lba, rid_lba_hi, .rid_notfound_pop
cmp byte [es:di], 0
call fat_next_checked
mov [cs:ff_entry_lba], ax
mov ax, [es:di+26]
find_dir_free:
call cluster_lba
LOAD_SUBDIR_SECTOR rid_lba, rid_lba_hi, .sd_full
cmp byte [es:di], 0
call fat_next_checked
call fat_alloc_cluster
call fat_set
call write_sector
call flush_fat
.sd_rollback:
call fat_set
call flush_fat

Tests that pin this

scripts/test_findedge.pyscripts/test_dirextfail.pyscripts/test_dirextrollback.pyscripts/test_subdir_cache.py

Directory slots are loaded and flushed explicitly

Root and subdirectory updates use different buffers but one write contract.

load_dir_slot remembers the target LBA and offset before choosing the backing buffer. Root slots already live in ROOT_SEG; subdirectory slots are read through the subdirectory cache into SEC_BUF so callers can modify the entry in place.

flush_handle_dir_entry is the close/commit path for size, date, time, and first-cluster metadata. It reloads the slot, stores handle fields into the directory entry, and flushes the exact sector back to disk.

Close, commit, and file-time updates enter through flush_dirty_handle_dir_entry: data is flushed first, then a spare per-handle dirty byte decides whether the directory sector actually needs rewriting.

src/kernel/fs.incNASM · 16-bit
1
4
6
25
29
31
38
40
47
48
82
88
91
97
103
109
118
129
132
135
144
150
152
159
160
165
167
dir_lba_root_base:
cmp ax, [cs:krsta]
cmp ax, [cs:kdsta]
load_dir_slot:
call dir_lba_root_base
mov ax, ROOT_SEG
mov dx, [cs:dir_flush_lba_hi]
call load_subdir_sector_cached
flush_dir_slot:
call subdir_cache_invalidate
flush_dir_sector: PERF_INC perf_dir_flushes
call dir_lba_root_base
call flush_root_sector
mov ax, SEC_BUF
call write_sector
flush_handle_dir_entry:
mov ax, [cs:si+handles+H_DIR_LBA]
call load_dir_slot
call store_handle_dir_fields
call flush_dir_slot
store_handle_dir_fields:
mov ax, [cs:si+handles+H_CLUSTER]
mov ax, [cs:si+handles+H_SIZE_LO]
flush_dirty_handle_dir_entry:
cmp byte [cs:si+handles+H_DRIVE+1], 0
call flush_handle_dir_entry
mov byte [cs:si+handles+H_DRIVE+1], 0

Tests that pin this

scripts/test_commit.pyscripts/test_termflush.pyscripts/test_savewrite.pyscripts/test_metafail.pyscripts/test_subdir_cache.pyscripts/bench_metadata.py

Reads cache; hard-disk writes coalesce

Handle I/O keeps cluster walking fast while hard-disk writes merge small records.

Reads convert the current file position to a cluster index and sector-in-cluster, use a shared FAT chain-position cache when possible, and direct-read aligned full-sector requests of at least two sectors. Smaller sequential reads keep a bounded window in READ_CACHE_BUF: non-sequential misses read one sector, while immediate continuation misses prefetch up to four sectors capped by the current FAT cluster and EOF. If the read targets the dirty write-back sector, LainDOS flushes it and fills READ_CACHE_BUF from WRITE_CACHE_BUF rather than rereading stale media data.

Writes allocate a first cluster on demand, extend chains with wf_get_cluster, read partial target sectors into WRITE_CACHE_BUF, patch only the requested bytes, and mark that sector dirty. On hard disks the dirty sector is deferred until the next uncached FAT chain walk, another sector, a read, close/commit, disk reset, or drive switch forces a flush; floppy writes flush immediately. Sparse writes allocate FAT chain gaps without zero-filling every sector.

src/kernel/int21.incNASM · 16-bit
1709
3643
3174
3193
3205
3235
3237
3273
3283
3287
3319
3494
3532
2309
3556
3607
3565
3632
3607
3638
3643
3646
3651
3662
3680
3689
3692
3796
mov ax, si
call cluster_lba
call read_sectors
cmp byte [cs:rf_cache_valid], 1
cmp cl, [cs:rf_cache_count]
mov byte [cs:rf_cache_count], READ_CACHE_SECTORS
mov cl, [cs:kspc]
call wf_fill_read_cache_from_write
mov dx, READ_CACHE_BUF
call read_sectors
mov dx, [cs:rf_cache_seg]
.write_file: PERF_INC perf_write_calls
call activate_drive_for_handle
call fat_alloc_cluster
call wf_flush_sector_cache
call wf_flush_sector_cache
call fat_alloc_cluster
cmp ax, [cs:wf_cluster_index]
call wf_flush_sector_cache
call wf_get_cluster
call cluster_lba
call wf_prepare_sector_cache
mov dx, WRITE_CACHE_BUF
call read_sector
mov ax, WRITE_CACHE_BUF
call wf_mark_sector_cache
call wf_flush_sector_cache
mov ax, [cs:wf_written]

Tests that pin this

scripts/test_readcache.pyscripts/test_rwedge.pyscripts/test_seekedge.py

Mutations flush directory and FAT state

Create, truncate, delete, rename, and commit keep disk images inspectable after exit.

Create and create-new share one path: parse the parent, find an existing entry or free slot, reject open/read-only conflicts, truncate old chains when replacing, write a fresh directory entry, and return a handle bound to that slot. Fault-injection builds can force a post-allocation flush_dir_slot failure and query the handle count through AH=F0h to prove the failed create does not leak an open slot.

Delete marks the directory entry E5h before freeing its FAT chain; rename is same-directory only and rejects open/read-only entries; commit and close flush handle metadata and FAT state so host-side image checks see durable data.

src/kernel/int21.incNASM · 16-bit
2447
2653
2657
2480
2493
2510
2511
2512
2518
995
2545
3862
3875
3879
3889
3892
3895
3896
5093
5109
5115
5121
5137
5151
5156
5223
5205
5255
.create_file:
call parse_root_path
call find_in_dir
call find_dir_free
call entry_has_open_handle
mov si, [cs:cf_first_cluster]
call fat_free_chain
call flush_fat
call load_dir_slot
mov si, name_buf
call flush_dir_slot
.delete_file:
call parse_root_path
call find_in_dir
mov byte [es:di], 0xE5
call flush_dir_slot
call fat_free_chain
call flush_fat
.rename_file:
call parse_root_path
call find_in_dir
call entry_has_open_handle
cmp ax, [cs:rn_dir_cluster]
mov si, name_buf
call flush_dir_slot
.commit_file:
call wf_flush_handle_dir_entry
call flush_fat

Tests that pin this

scripts/test_createapi.pyscripts/test_handleleak.pyscripts/test_savewrite.pyscripts/test_dirmut.pyscripts/test_rnguard.py