fat: multi-sector block transfers (B5a)

The FAT engine read and wrote one sector per device command — one SCSI
READ(10)/WRITE(10) over USB Bulk-Only Transport per 512 bytes, so every
file read, log write, and cluster fill paid a full USB round trip per
sector. The lower stack (runtime.block, the block protocol, usb-storage's
read10/write10 + count*block_size data stage) already carried a
multi-sector count; only the engine's BlockDevice interface and IpcBlock
were single-sector.

Widen BlockDevice to move a run of `count` contiguous sectors per call
(readBlock/writeBlock kept as count=1 wrappers, so metadata call sites —
FAT sectors, directory entries — are untouched). readFile and writeFile
now coalesce the aligned full-sector middle of a transfer into one command
(capped at the cluster boundary and the 4 KiB bounce = 8 sectors), reading
straight into / writing straight from the caller's buffer with no staging
copy. Full-sector writes skip the read-modify-write entirely, since they
overwrite the whole sector. Partial head/tail sectors keep the per-sector
RMW path.

IpcBlock passes count through to the block driver and sizes its bounce from
engine.max_transfer_sectors (already 4 KiB — no new allocation). A new
spc=8 engine test proves runs coalesce (a 21-sector overwrite drops from
>=21 writes to <=5) and that reads/writes round-trip byte-identical,
including an unaligned offset spanning a cluster boundary.

Full QEMU suite 92/92.
This commit is contained in:
Daniel Samson
2026-07-22 00:11:51 +01:00
parent ded555961c
commit 6e261ccda1
2 changed files with 162 additions and 27 deletions
+148 -17
View File
@@ -17,21 +17,37 @@
const std = @import("std"); const std = @import("std");
const on_disk = @import("on-disk.zig"); const on_disk = @import("on-disk.zig");
/// The most sectors one multi-sector transfer moves. Runs of contiguous full
/// sectors within a cluster are coalesced into a single device command up to this
/// cap; it bounds the DMA bounce buffer the fat server must provide (see
/// `IpcBlock` in fat.zig — its bounce is `max_transfer_sectors * 512` bytes).
pub const max_transfer_sectors = 8;
/// A block device the engine reads and writes in fixed-size blocks. The two /// A block device the engine reads and writes in fixed-size blocks. The two
/// function pointers let the same engine run over a real `.block` driver or a /// function pointers move a *run* of `count` contiguous sectors in one call, so a
/// RAM buffer (the tests). /// bulk file read/write costs one device round-trip per run rather than one per
/// sector; the same engine runs over a real `.block` driver (which turns a run
/// into one multi-sector SCSI command) or a RAM buffer (the tests). Metadata
/// accesses (FAT sectors, directory sectors) use the single-block convenience
/// wrappers below.
pub const BlockDevice = struct { pub const BlockDevice = struct {
context: *anyopaque, context: *anyopaque,
block_size: u32, block_size: u32,
block_count: u64, block_count: u64,
readBlockFn: *const fn (context: *anyopaque, lba: u64, buffer: []u8) bool, readBlocksFn: *const fn (context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool,
writeBlockFn: *const fn (context: *anyopaque, lba: u64, buffer: []const u8) bool, writeBlocksFn: *const fn (context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool,
pub fn readBlocks(self: BlockDevice, lba: u64, count: u32, buffer: []u8) bool {
return self.readBlocksFn(self.context, lba, count, buffer);
}
pub fn writeBlocks(self: BlockDevice, lba: u64, count: u32, buffer: []const u8) bool {
return self.writeBlocksFn(self.context, lba, count, buffer);
}
pub fn readBlock(self: BlockDevice, lba: u64, buffer: []u8) bool { pub fn readBlock(self: BlockDevice, lba: u64, buffer: []u8) bool {
return self.readBlockFn(self.context, lba, buffer); return self.readBlocksFn(self.context, lba, 1, buffer);
} }
pub fn writeBlock(self: BlockDevice, lba: u64, buffer: []const u8) bool { pub fn writeBlock(self: BlockDevice, lba: u64, buffer: []const u8) bool {
return self.writeBlockFn(self.context, lba, buffer); return self.writeBlocksFn(self.context, lba, 1, buffer);
} }
}; };
@@ -87,6 +103,16 @@ pub const FileSystem = struct {
fn blockWrite(self: *FileSystem, lba: u64, buffer: []const u8) bool { fn blockWrite(self: *FileSystem, lba: u64, buffer: []const u8) bool {
return self.device.writeBlock(self.base_lba + lba, buffer); return self.device.writeBlock(self.base_lba + lba, buffer);
} }
// Move `count` contiguous full sectors in one device command. `buffer` must be
// exactly count*sector_size and sector-aligned in meaning (whole sectors only);
// callers use these for the aligned middle of a file transfer, falling back to
// the single-sector read-modify-write path for partial head/tail sectors.
fn blockReadRun(self: *FileSystem, lba: u64, count: u32, buffer: []u8) bool {
return self.device.readBlocks(self.base_lba + lba, count, buffer);
}
fn blockWriteRun(self: *FileSystem, lba: u64, count: u32, buffer: []const u8) bool {
return self.device.writeBlocks(self.base_lba + lba, count, buffer);
}
/// Mount the filesystem on `device`: either a bare FAT with its boot sector at /// Mount the filesystem on `device`: either a bare FAT with its boot sector at
/// LBA 0, or (as QEMU's VVFAT and most real USB sticks present it) an MBR- /// LBA 0, or (as QEMU's VVFAT and most real USB sticks present it) an MBR-
@@ -544,6 +570,7 @@ pub const FileSystem = struct {
const want = @min(buffer.len, available); const want = @min(buffer.len, available);
const cluster_bytes = self.geometry.sectors_per_cluster * sector_size; const cluster_bytes = self.geometry.sectors_per_cluster * sector_size;
const spc = self.geometry.sectors_per_cluster;
var produced: usize = 0; var produced: usize = 0;
var position = offset; var position = offset;
while (produced < want) { while (produced < want) {
@@ -551,7 +578,23 @@ pub const FileSystem = struct {
const in_cluster = position % cluster_bytes; const in_cluster = position % cluster_bytes;
const sector_in_cluster = in_cluster / sector_size; const sector_in_cluster = in_cluster / sector_size;
const in_sector = in_cluster % sector_size; const in_sector = in_cluster % sector_size;
if (!self.blockRead(self.clusterSector(cluster, sector_in_cluster), &self.sector)) break; const lba = self.clusterSector(cluster, sector_in_cluster);
// Aligned middle: read a run of whole sectors straight into the caller's
// buffer in one device command (no per-sector round-trip, no staging
// copy). Bounded by the sectors left in this cluster (the next cluster
// may not be physically contiguous), the transfer cap, and the request.
if (in_sector == 0 and want - produced >= sector_size) {
const full = @min((want - produced) / sector_size, spc - sector_in_cluster);
const run: u32 = @intCast(@min(full, max_transfer_sectors));
if (!self.blockReadRun(lba, run, buffer[produced .. produced + run * sector_size])) break;
produced += run * sector_size;
position += run * sector_size;
continue;
}
// Partial head or tail sector: read one sector, copy the slice needed.
if (!self.blockRead(lba, &self.sector)) break;
const n = @min(want - produced, sector_size - in_sector); const n = @min(want - produced, sector_size - in_sector);
@memcpy(buffer[produced .. produced + n], self.sector[in_sector .. in_sector + n]); @memcpy(buffer[produced .. produced + n], self.sector[in_sector .. in_sector + n]);
produced += n; produced += n;
@@ -573,6 +616,7 @@ pub const FileSystem = struct {
node.first_cluster = fresh; node.first_cluster = fresh;
} }
const spc = self.geometry.sectors_per_cluster;
var consumed: usize = 0; var consumed: usize = 0;
var position = offset; var position = offset;
while (consumed < data.len) { while (consumed < data.len) {
@@ -581,7 +625,22 @@ pub const FileSystem = struct {
const sector_in_cluster = in_cluster / sector_size; const sector_in_cluster = in_cluster / sector_size;
const in_sector = in_cluster % sector_size; const in_sector = in_cluster % sector_size;
const lba = self.clusterSector(cluster, sector_in_cluster); const lba = self.clusterSector(cluster, sector_in_cluster);
// Read-modify-write the sector for a partial write.
// Aligned middle: write a run of whole sectors straight from the caller's
// data in one device command. These sectors are fully overwritten, so no
// read-modify-write is needed — a double saving over the per-sector RMW
// path. Bounded by the sectors left in this (already-resolved) cluster,
// the transfer cap, and the data remaining.
if (in_sector == 0 and data.len - consumed >= sector_size) {
const full = @min((data.len - consumed) / sector_size, spc - sector_in_cluster);
const run: u32 = @intCast(@min(full, max_transfer_sectors));
if (!self.blockWriteRun(lba, run, data[consumed .. consumed + run * sector_size])) break;
consumed += run * sector_size;
position += run * sector_size;
continue;
}
// Partial head or tail sector: read-modify-write one sector.
if (!self.blockRead(lba, &self.sector)) break; if (!self.blockRead(lba, &self.sector)) break;
const n = @min(data.len - consumed, sector_size - in_sector); const n = @min(data.len - consumed, sector_size - in_sector);
@memcpy(self.sector[in_sector .. in_sector + n], data[consumed .. consumed + n]); @memcpy(self.sector[in_sector .. in_sector + n], data[consumed .. consumed + n]);
@@ -1049,18 +1108,29 @@ pub const FileSystem = struct {
const RamDisk = struct { const RamDisk = struct {
bytes: []u8, bytes: []u8,
fn readBlock(context: *anyopaque, lba: u64, buffer: []u8) bool { // Instrumentation for the multi-sector tests: how many device commands were
// issued, and the largest run (in sectors) any single command carried.
reads: usize = 0,
writes: usize = 0,
max_run: u32 = 0,
fn readBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool {
const self: *RamDisk = @ptrCast(@alignCast(context)); const self: *RamDisk = @ptrCast(@alignCast(context));
const len = @as(usize, count) * sector_size;
const start = lba * sector_size; const start = lba * sector_size;
if (start + sector_size > self.bytes.len) return false; if (start + len > self.bytes.len or buffer.len < len) return false;
@memcpy(buffer[0..sector_size], self.bytes[start .. start + sector_size]); @memcpy(buffer[0..len], self.bytes[start .. start + len]);
self.reads += 1;
if (count > self.max_run) self.max_run = count;
return true; return true;
} }
fn writeBlock(context: *anyopaque, lba: u64, buffer: []const u8) bool { fn writeBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool {
const self: *RamDisk = @ptrCast(@alignCast(context)); const self: *RamDisk = @ptrCast(@alignCast(context));
const len = @as(usize, count) * sector_size;
const start = lba * sector_size; const start = lba * sector_size;
if (start + sector_size > self.bytes.len) return false; if (start + len > self.bytes.len or buffer.len < len) return false;
@memcpy(self.bytes[start .. start + sector_size], buffer[0..sector_size]); @memcpy(self.bytes[start .. start + len], buffer[0..len]);
self.writes += 1;
if (count > self.max_run) self.max_run = count;
return true; return true;
} }
fn device(self: *RamDisk) BlockDevice { fn device(self: *RamDisk) BlockDevice {
@@ -1068,8 +1138,8 @@ const RamDisk = struct {
.context = self, .context = self,
.block_size = sector_size, .block_size = sector_size,
.block_count = self.bytes.len / sector_size, .block_count = self.bytes.len / sector_size,
.readBlockFn = readBlock, .readBlocksFn = readBlocks,
.writeBlockFn = writeBlock, .writeBlocksFn = writeBlocks,
}; };
} }
}; };
@@ -1078,13 +1148,17 @@ const RamDisk = struct {
// two reserved entries, an empty root directory. Enough for the engine to mount // two reserved entries, an empty root directory. Enough for the engine to mount
// and operate on. // and operate on.
fn formatFat16(bytes: []u8) void { fn formatFat16(bytes: []u8) void {
formatFat16Spc(bytes, 1);
}
fn formatFat16Spc(bytes: []u8, sectors_per_cluster: u8) void {
@memset(bytes, 0); @memset(bytes, 0);
const total_sectors: u16 = @intCast(bytes.len / sector_size); const total_sectors: u16 = @intCast(bytes.len / sector_size);
var bpb = std.mem.zeroes(on_disk.BiosParameterBlock); var bpb = std.mem.zeroes(on_disk.BiosParameterBlock);
bpb.jump = .{ 0xEB, 0x3C, 0x90 }; bpb.jump = .{ 0xEB, 0x3C, 0x90 };
bpb.oem_name = "MSWIN4.1".*; bpb.oem_name = "MSWIN4.1".*;
bpb.bytes_per_sector = sector_size; bpb.bytes_per_sector = sector_size;
bpb.sectors_per_cluster = 1; bpb.sectors_per_cluster = sectors_per_cluster;
bpb.reserved_sector_count = 1; bpb.reserved_sector_count = 1;
bpb.fat_count = 2; bpb.fat_count = 2;
bpb.root_entry_count = 512; bpb.root_entry_count = 512;
@@ -1159,6 +1233,63 @@ test "create, write, read back a file through the engine" {
try std.testing.expect(fs.listEntry(fs.rootNode(), 1) == null); try std.testing.expect(fs.listEntry(fs.rootNode(), 1) == null);
} }
test "multi-sector transfers coalesce and round-trip identically" {
const allocator = std.testing.allocator;
const bytes = try allocator.alloc(u8, 8000 * sector_size);
defer allocator.free(bytes);
// 8 sectors per cluster: aligned runs can hit the max_transfer_sectors cap.
formatFat16Spc(bytes, 8);
var disk = RamDisk{ .bytes = bytes };
var fs = FileSystem.mount(disk.device()).?;
// A payload that is many whole sectors plus a partial tail, all sector-aligned
// at the start (offset 0): the aligned-run path should carry the bulk.
const size = 20 * sector_size + 100;
const payload = try allocator.alloc(u8, size);
defer allocator.free(payload);
for (payload, 0..) |*b, i| b.* = @truncate(i * 7 + 3);
var node = fs.createFile(fs.rootNode(), "BIG.BIN").?;
const written = fs.writeFile(&node, 0, payload);
try std.testing.expectEqual(size, written);
// Overwrite the same region (no allocation, no cluster-zeroing, no FAT writes),
// so the command count reflects only data movement. The 21 sectors it spans
// (three clusters of 8) coalesce into a handful of runs — far fewer than the
// >=21 writes a per-sector engine would issue, and each full sector skips the
// read-modify-write entirely.
disk.writes = 0;
disk.max_run = 0;
_ = fs.writeFile(&node, 0, payload);
// A per-sector engine would issue >=21 writes (one RMW per sector); coalescing
// plus skip-RMW-on-full-sector brings it to the three aligned runs + one partial
// tail + the directory-entry writeback.
try std.testing.expect(disk.max_run > 1);
try std.testing.expect(disk.writes <= 5);
const resolved = fs.resolve("/BIG.BIN").?;
try std.testing.expectEqual(@as(u32, size), resolved.size);
// Read back the whole file and compare byte-for-byte.
const readback = try allocator.alloc(u8, size);
defer allocator.free(readback);
disk.reads = 0;
disk.max_run = 0;
const got = fs.readFile(resolved, 0, readback);
try std.testing.expectEqual(size, got);
try std.testing.expectEqualSlices(u8, payload, readback);
try std.testing.expect(disk.max_run > 1);
try std.testing.expect(disk.reads < 21);
// Equivalence at an unaligned offset spanning a cluster boundary: same bytes as
// a naive per-sector engine would produce (the payload we wrote).
var window: [1000]u8 = undefined;
const n = fs.readFile(resolved, 300, &window);
try std.testing.expectEqual(@as(usize, 1000), n);
try std.testing.expectEqualSlices(u8, payload[300 .. 300 + 1000], window[0..1000]);
}
test "truncate frees the chain and zeroes the file" { test "truncate frees the chain and zeroes the file" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const bytes = try allocator.alloc(u8, 5000 * sector_size); const bytes = try allocator.alloc(u8, 5000 * sector_size);
+14 -10
View File
@@ -22,20 +22,24 @@ const mount_point = "/mnt/usb";
// buffer the driver reads/writes by physical address. // buffer the driver reads/writes by physical address.
const IpcBlock = struct { const IpcBlock = struct {
device: runtime.block.Device, device: runtime.block.Device,
bounce: dma.Region, bounce: dma.Region, // engine.max_transfer_sectors * 512 bytes
fn readBlock(context: *anyopaque, lba: u64, buffer: []u8) bool { fn readBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool {
const self: *IpcBlock = @ptrCast(@alignCast(context)); const self: *IpcBlock = @ptrCast(@alignCast(context));
if (!self.device.read(lba, 1, self.bounce.physical)) return false; if (count == 0 or count > engine.max_transfer_sectors) return false;
const len = count * 512;
if (!self.device.read(lba, count, self.bounce.physical)) return false;
const source: [*]const u8 = @ptrFromInt(self.bounce.virtual); const source: [*]const u8 = @ptrFromInt(self.bounce.virtual);
@memcpy(buffer[0..512], source[0..512]); @memcpy(buffer[0..len], source[0..len]);
return true; return true;
} }
fn writeBlock(context: *anyopaque, lba: u64, buffer: []const u8) bool { fn writeBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool {
const self: *IpcBlock = @ptrCast(@alignCast(context)); const self: *IpcBlock = @ptrCast(@alignCast(context));
if (count == 0 or count > engine.max_transfer_sectors) return false;
const len = count * 512;
const destination: [*]u8 = @ptrFromInt(self.bounce.virtual); const destination: [*]u8 = @ptrFromInt(self.bounce.virtual);
@memcpy(destination[0..512], buffer[0..512]); @memcpy(destination[0..len], buffer[0..len]);
if (!self.device.write(lba, 1, self.bounce.physical)) return false; if (!self.device.write(lba, count, self.bounce.physical)) return false;
device_dirty = true; // a block reached the device; a close will flush it device_dirty = true; // a block reached the device; a close will flush it
return true; return true;
} }
@@ -108,15 +112,15 @@ fn tryBringUp() void {
_ = runtime.system.write("/system/services/fat: block geometry unavailable\n"); _ = runtime.system.write("/system/services/fat: block geometry unavailable\n");
return; return;
}; };
const bounce = dma.alloc(4096, dma.coherent) orelse return; const bounce = dma.alloc(engine.max_transfer_sectors * 512, dma.coherent) orelse return;
ipc_block = .{ .device = device, .bounce = bounce }; ipc_block = .{ .device = device, .bounce = bounce };
const block_device = engine.BlockDevice{ const block_device = engine.BlockDevice{
.context = &ipc_block, .context = &ipc_block,
.block_size = geometry.block_size, .block_size = geometry.block_size,
.block_count = geometry.block_count, .block_count = geometry.block_count,
.readBlockFn = IpcBlock.readBlock, .readBlocksFn = IpcBlock.readBlocks,
.writeBlockFn = IpcBlock.writeBlock, .writeBlocksFn = IpcBlock.writeBlocks,
}; };
filesystem = engine.FileSystem.mount(block_device) orelse { filesystem = engine.FileSystem.mount(block_device) orelse {
_ = runtime.system.write("/system/services/fat: not a FAT filesystem\n"); _ = runtime.system.write("/system/services/fat: not a FAT filesystem\n");