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
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
2 changed files with 162 additions and 27 deletions

View File

@ -17,21 +17,37 @@
const std = @import("std");
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
/// function pointers let the same engine run over a real `.block` driver or a
/// RAM buffer (the tests).
/// function pointers move a *run* of `count` contiguous sectors in one call, so a
/// 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 {
context: *anyopaque,
block_size: u32,
block_count: u64,
readBlockFn: *const fn (context: *anyopaque, lba: u64, buffer: []u8) bool,
writeBlockFn: *const fn (context: *anyopaque, lba: u64, buffer: []const u8) bool,
readBlocksFn: *const fn (context: *anyopaque, lba: u64, count: u32, buffer: []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 {
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 {
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 {
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
/// 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 cluster_bytes = self.geometry.sectors_per_cluster * sector_size;
const spc = self.geometry.sectors_per_cluster;
var produced: usize = 0;
var position = offset;
while (produced < want) {
@ -551,7 +578,23 @@ pub const FileSystem = struct {
const in_cluster = position % cluster_bytes;
const sector_in_cluster = 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);
@memcpy(buffer[produced .. produced + n], self.sector[in_sector .. in_sector + n]);
produced += n;
@ -573,6 +616,7 @@ pub const FileSystem = struct {
node.first_cluster = fresh;
}
const spc = self.geometry.sectors_per_cluster;
var consumed: usize = 0;
var position = offset;
while (consumed < data.len) {
@ -581,7 +625,22 @@ pub const FileSystem = struct {
const sector_in_cluster = in_cluster / sector_size;
const in_sector = in_cluster % sector_size;
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;
const n = @min(data.len - consumed, sector_size - in_sector);
@memcpy(self.sector[in_sector .. in_sector + n], data[consumed .. consumed + n]);
@ -1049,18 +1108,29 @@ pub const FileSystem = struct {
const RamDisk = struct {
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 len = @as(usize, count) * sector_size;
const start = lba * sector_size;
if (start + sector_size > self.bytes.len) return false;
@memcpy(buffer[0..sector_size], self.bytes[start .. start + sector_size]);
if (start + len > self.bytes.len or buffer.len < len) return false;
@memcpy(buffer[0..len], self.bytes[start .. start + len]);
self.reads += 1;
if (count > self.max_run) self.max_run = count;
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 len = @as(usize, count) * sector_size;
const start = lba * sector_size;
if (start + sector_size > self.bytes.len) return false;
@memcpy(self.bytes[start .. start + sector_size], buffer[0..sector_size]);
if (start + len > self.bytes.len or buffer.len < len) return false;
@memcpy(self.bytes[start .. start + len], buffer[0..len]);
self.writes += 1;
if (count > self.max_run) self.max_run = count;
return true;
}
fn device(self: *RamDisk) BlockDevice {
@ -1068,8 +1138,8 @@ const RamDisk = struct {
.context = self,
.block_size = sector_size,
.block_count = self.bytes.len / sector_size,
.readBlockFn = readBlock,
.writeBlockFn = writeBlock,
.readBlocksFn = readBlocks,
.writeBlocksFn = writeBlocks,
};
}
};
@ -1078,13 +1148,17 @@ const RamDisk = struct {
// two reserved entries, an empty root directory. Enough for the engine to mount
// and operate on.
fn formatFat16(bytes: []u8) void {
formatFat16Spc(bytes, 1);
}
fn formatFat16Spc(bytes: []u8, sectors_per_cluster: u8) void {
@memset(bytes, 0);
const total_sectors: u16 = @intCast(bytes.len / sector_size);
var bpb = std.mem.zeroes(on_disk.BiosParameterBlock);
bpb.jump = .{ 0xEB, 0x3C, 0x90 };
bpb.oem_name = "MSWIN4.1".*;
bpb.bytes_per_sector = sector_size;
bpb.sectors_per_cluster = 1;
bpb.sectors_per_cluster = sectors_per_cluster;
bpb.reserved_sector_count = 1;
bpb.fat_count = 2;
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);
}
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" {
const allocator = std.testing.allocator;
const bytes = try allocator.alloc(u8, 5000 * sector_size);

View File

@ -22,20 +22,24 @@ const mount_point = "/mnt/usb";
// buffer the driver reads/writes by physical address.
const IpcBlock = struct {
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));
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);
@memcpy(buffer[0..512], source[0..512]);
@memcpy(buffer[0..len], source[0..len]);
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));
if (count == 0 or count > engine.max_transfer_sectors) return false;
const len = count * 512;
const destination: [*]u8 = @ptrFromInt(self.bounce.virtual);
@memcpy(destination[0..512], buffer[0..512]);
if (!self.device.write(lba, 1, self.bounce.physical)) return false;
@memcpy(destination[0..len], buffer[0..len]);
if (!self.device.write(lba, count, self.bounce.physical)) return false;
device_dirty = true; // a block reached the device; a close will flush it
return true;
}
@ -108,15 +112,15 @@ fn tryBringUp() void {
_ = runtime.system.write("/system/services/fat: block geometry unavailable\n");
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 };
const block_device = engine.BlockDevice{
.context = &ipc_block,
.block_size = geometry.block_size,
.block_count = geometry.block_count,
.readBlockFn = IpcBlock.readBlock,
.writeBlockFn = IpcBlock.writeBlock,
.readBlocksFn = IpcBlock.readBlocks,
.writeBlocksFn = IpcBlock.writeBlocks,
};
filesystem = engine.FileSystem.mount(block_device) orelse {
_ = runtime.system.write("/system/services/fat: not a FAT filesystem\n");