diff options
Diffstat (limited to 'src/board_memory.zig')
| -rw-r--r-- | src/board_memory.zig | 323 |
1 files changed, 323 insertions, 0 deletions
diff --git a/src/board_memory.zig b/src/board_memory.zig new file mode 100644 index 00000000..853ae5da --- /dev/null +++ b/src/board_memory.zig @@ -0,0 +1,323 @@ +//! The board's own address space, as text: the Peek, Poke and Hexdump +//! builtins' whole implementation. +//! +//! BARE METAL ONLY (`enabled` below), and the reason is not caution but +//! honesty: with no OS there is no MMU, no supervisor and no process — the +//! editor IS the system software — so every one of the 2^32 addresses is +//! legitimately this program's to read and write, and a word that could name +//! only some of them would be lying about where it is running. Under an OS the +//! same three words would be either a segfault or a syscall stub, so they are +//! absent from those builds entirely rather than present and refusing. +//! +//! Everything here goes through `*allowzero volatile` pointers. A peripheral +//! register is not memory: reading UART_STATUS twice is two reads and must not +//! be folded into one, a write to a write-only command register has no +//! observable value for the optimizer to keep, and address 0 is an ordinary +//! (unmapped) address on this bus rather than the null Zig assumes it is. +//! +//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in +//! an output buffer the same way Jumplist and Config do: an output buffer is a +//! file pane, so every motion, chord and Look works on a dump for free — you +//! can right-click an address in a hexdump row and Peek it. +const std = @import("std"); +const builtin = @import("builtin"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const output_pane = @import("output_pane.zig"); + +/// The one gate, and it is derived from the TARGET rather than from +/// `pardes.platform`: these three words are not a product configuration, they +/// are a property of running with no operating system under you, and a +/// predicate spelled out of `builtin` cannot drift from that the way a +/// hand-maintained platform enum can. Same idiom as allocators.zig's tiers. +/// +/// Wasm is `freestanding` too — that is the `web` platform — and it is exactly +/// what this must exclude: inside the browser's sandbox an address is an offset +/// into a linear memory the engine owns, so a "peek" there would read a number +/// that means nothing about any machine and a "poke" would corrupt the heap +/// this same editor is running out of. Bare metal is the freestanding target +/// whose addresses are the bus's. +pub const enabled = builtin.os.tag == .freestanding and !builtin.target.cpu.arch.isWasm(); + +// `pardes.platform` is not the gate, but it IS an independent witness, so each +// of the three interesting builds proves its own half of the predicate rather +// than leaving "wasm is freestanding" as a comment nobody re-checks. The one +// that matters is the middle line: without the `isWasm` term above, the web +// build would silently hand a browser tab a Poke that writes into the linear +// memory this editor's own heap lives in. +comptime { + if (pardes.hosted and enabled) @compileError("an OS is not bare metal"); + if (pardes.platform == .web and enabled) @compileError("wasm is not bare metal"); + if (pardes.platform == .p4 and !enabled) @compileError("the P4 firmware is bare metal"); +} + +/// How much of the address space ONE command may render. +/// +/// The number is set by the console, not by the memory: UART0 runs at 115200 +/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of +/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of +/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB +/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge +/// the only console the board has for eleven hours, with no way to interrupt +/// it, which makes an unbounded dump not a slow command but a lost session. +/// +/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024` +/// and `Hexdump a 4096` cover exactly the same bytes. +pub const max_bytes: u32 = 4096; +pub const max_words: u32 = max_bytes / 4; + +/// One address past the last: the reads below are bounded by this rather than +/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently +/// show you the bottom of the space labelled with top-of-space addresses. +const space: u64 = 1 << 32; + +pub const Error = error{ + MissingAddress, + BadAddress, + BadCount, + MissingValue, + BadValue, + /// the ONE fault this file exists to prevent by hand: the RISC-V core + /// traps an unaligned 32-bit access, and a trap in firmware with no + /// handler is a watchdog reset that takes the session with it. Reported on + /// the message row instead. + MisalignedAddress, + ExtraArgument, +}; + +/// hex (`0x4ff40000`), decimal (`1341718528`), and — for free, from base 0 — +/// binary and octal. A bare `4ff40000` is deliberately NOT hex: it is a +/// legal-looking decimal number, so guessing the base would make one typo +/// silently address somewhere else entirely. +fn parseAddr(tok: []const u8) Error!u32 { + return std.fmt.parseInt(u32, tok, 0) catch return Error.BadAddress; +} + +fn parseCount(tok: []const u8) Error!u64 { + return std.fmt.parseInt(u64, tok, 0) catch return Error.BadCount; +} + +fn parseValue(tok: []const u8) Error!u32 { + return std.fmt.parseInt(u32, tok, 0) catch return Error.BadValue; +} + +/// A 32-bit peripheral or RAM read that the compiler may neither elide, +/// duplicate, reorder past another access, nor narrow. +fn readWord(addr: u32) u32 { + const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); + return cell.*; +} + +fn writeWord(addr: u32, value: u32) void { + const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); + cell.* = value; +} + +fn readByte(addr: u32) u8 { + const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); + return cell.*; +} + +const Limit = enum { + /// the 4 KiB console cap above + console, + /// the end of the 32-bit address space + space, +}; + +/// How many units this command will actually show, and WHY that is fewer than +/// you asked for when it is. Never silent: the note below becomes the buffer's +/// FIRST line, which is the one place a clamp cannot be missed — a trailing +/// note on a 256-row dump is a note you scroll past. +const Extent = struct { + count: u32, + /// the tighter of the two bounds, or null when neither applied + limit: ?Limit, +}; + +fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { + var count = requested; + var limit: ?Limit = null; + if (count > cap) { + count = cap; + limit = .console; + } + const fits = (space - addr) / unit; + if (count > fits) { + count = fits; + limit = .space; + } + return .{ .count = @intCast(count), .limit = limit }; +} + +fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { + switch (e.limit orelse return) { + .console => try w.print( + "clamped: {d} {s} requested, {d} shown ({d}-byte cap, one 115200-baud console)\n", + .{ requested, unit_name, e.count, max_bytes }, + ), + .space => try w.print( + "clamped: {d} {s} requested, {d} shown (the 32-bit address space ends at 0x100000000)\n", + .{ requested, unit_name, e.count }, + ), + } +} + +// The two bounds and their reporting, on the one part of this file that is +// pure arithmetic and therefore testable on any target — the accesses +// themselves are only meaningful on the board. +test "the clamp reports the tighter bound and never wraps the address space" { + const eq = std.testing.expectEqual; + // neither bound applied: what you asked for, and nothing to report + try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); + // the console cap, in words and in bytes + try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); + // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping + // would show the BOTTOM of the space under top-of-space addresses + try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); + try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); + // ...including the row that has no whole word left in it + try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); + // both bounds at once: the tighter one is the one reported + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); +} + +test "a clamp note is written exactly when something was clamped" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); + try std.testing.expectEqualStrings("", w.buffered()); + + try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); + try std.testing.expectEqualStrings( + "clamped: 99999 words requested, 1024 shown (4096-byte cap, one 115200-baud console)\n", + w.buffered(), + ); + + w = .fixed(&buf); + try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); + try std.testing.expectEqualStrings( + "clamped: 64 bytes requested, 16 shown (the 32-bit address space ends at 0x100000000)\n", + w.buffered(), + ); +} + +test "an address is hex or decimal, and a bare hex-looking token is decimal" { + try std.testing.expectEqual(0x4ff40000, parseAddr("0x4ff40000")); + try std.testing.expectEqual(0x4ff40000, parseAddr("1341390848")); + // a bare hex-looking token is a decimal number, never a guess + try std.testing.expectError(Error.BadAddress, parseAddr("4ff40000")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); + try std.testing.expectError(Error.BadCount, parseCount("-1")); + try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); +} + +/// `Peek <addr> [count]` — count 32-bit words at addr, one `addr: value` row +/// each. One word per row rather than four so that every row carries its own +/// address: the rows are then ordinary Look targets, and `Peek` or `Poke` +/// chorded onto one re-reads or writes exactly that word. +pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 1; + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + const e = extent(addr, requested, 4, max_words); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "words"); + for (0..e.count) |i| { + const at = addr + @as(u32, @intCast(i * 4)); + try out.writer.print("0x{x:0>8}: 0x{x:0>8}\n", .{ at, readWord(at) }); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Peek }, content); +} + +/// `Poke <addr> <value>` — one 32-bit store, then one load back, both reported +/// on the message row. +/// +/// The READ-BACK is the whole point of the word and not a confirmation: on RAM +/// it always equals what you wrote and tells you nothing, and on MMIO it +/// almost never does — a write-only command register reads as 0, a W1C status +/// bit reads back cleared, a reserved field reads back masked, and a register +/// behind a gated clock reads back whatever the bus returns for nothing at +/// all. Printing only the value written would show you your own argument. +pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const value = try parseValue(it.next() orelse return Error.MissingValue); + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + writeWord(addr, value); + const back = readWord(addr); + var buf: [96]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint( + &buf, + "0x{x:0>8}: wrote 0x{x:0>8}, reads 0x{x:0>8}", + .{ addr, value, back }, + ) catch unreachable); +} + +/// `Hexdump <addr> [len]` — len bytes, 16 to a row, hex columns and an ASCII +/// gutter, in `hexdump -C`'s layout because that is the one everyone can +/// already read. BYTE reads, so a partial row at the end of the space is a +/// short row rather than a refusal, and no alignment is required: this is the +/// word you reach for when you do not yet know what is there. +pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 256; + if (it.next() != null) return Error.ExtraArgument; + + const e = extent(addr, requested, 1, max_bytes); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "bytes"); + var row: u32 = 0; + while (row < e.count) : (row += 16) { + const n = @min(@as(u32, 16), e.count - row); + var bytes: [16]u8 = undefined; + for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); + try out.writer.print("0x{x:0>8} ", .{addr + row}); + for (0..16) |i| { + // hexdump -C's gap after the eighth column: the eye counts to + // eight, not to sixteen + if (i == 8) try out.writer.writeByte(' '); + if (i < n) + try out.writer.print(" {x:0>2}", .{bytes[i]}) + else + try out.writer.writeAll(" "); + } + try out.writer.writeAll(" |"); + for (0..n) |i| try out.writer.writeByte( + if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', + ); + try out.writer.writeAll("|\n"); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Hexdump }, content); +} + +/// The shared tail. `fillResults` is the one public entry that REFILLS the +/// buffer a command already opened instead of stacking a twin beside it, which +/// is what a dump wants: peeking twenty addresses in a row is twenty renders +/// of one window on memory, not twenty panes. The empty argument is what makes +/// it one window — a dump is identified by the command, never by the address, +/// so a second Peek replaces the first rather than opening a buffer per +/// address and exhausting the pane slots. +/// +/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the +/// pane you typed the command in. `content` is gpa-owned and adopted there. +fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void { + const pane = p.panes[id] orelse { + p.gpa.free(content); + return error.MissingPane; + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + try output_pane.fillResults(p, id, dir, from, "", content, null); +} |
