1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
|
//! pardes, as ESP32-P4 firmware.
//!
//! There is no operating system under this. `_start` is the reset entry the second-stage bootloader
//! jumps to, and this file is the entire platform: a heap, a millisecond clock, and UART0.
//!
//! ## Where the editor is
//!
//! Not in this package. `../02-pardes-code` compiles its core for riscv32-freestanding and emits
//! ONE object exporting the six C functions declared below; `-Dpardes` links it. The seam is a file
//! rather than a package dependency for a reason recorded at length in `build.zig`: declaring the
//! editor as a `build.zig.zon` path dependency nested its ~30-package graph under this one and
//! broke every build in this repo, including the ones that have nothing to do with it.
//!
//! The seam is deliberately **bytes in, bytes out**. Everything that needs to know what a cell is -
//! vaxis, the ANSI encoder, the input parser, the capability handshake - lives on the far side,
//! next to the vaxis it is built against. What crosses is a byte stream in each direction, which is
//! exactly what a serial line is, so this file has no opinion about terminals at all.
//!
//! ## Where the memory is
//!
//! Measured on this die by `examples/memprobe.zig`, not read off a datasheet:
//!
//! 0x4FF02000..0x4FF3F000 244 KiB .data/.bss/.stack live at the bottom of this
//! 0x4FF3F000..0x4FF40000 4 KiB mask ROM .data/.bss - untouchable, ets_printf needs it
//! 0x4FF40000..0x4FFC0000 512 KiB handed to the editor as its entire heap
//!
//! The 512 KiB arrives as `__heap_start`/`__heap_end` from the generated linker script, so those
//! addresses are written down in exactly one place. The editor owns that span outright: it is
//! passed in at init and this file never allocates from it.
//!
//! PSRAM is not used. The board has 32 MB fitted and it would make all of this comfortable, but
//! ESP-IDF's own ESP32-P4 implementation runs past a thousand lines - MPLL, MSPI clocking, pin
//! drive and DQS, CS timing, mode registers, a connectivity check, and an entire timing-calibration
//! subsystem - and the mask ROM offers only MMU mapping, no device init. Touching it untrained
//! faults and hangs the core, which `examples/memprobe.zig` demonstrates on purpose.
const std = @import("std");
const soc = @import("soc");
const config = @import("config");
/// `-Dprof`: time the two phases of a keystroke on the board and print the cycle counts. A
/// diagnostic, not a feature - see the loop.
const prof = config.prof;
const hal = @import("hal");
const heapmod = @import("heap");
const uart = @import("uart.zig");
// ------------------------------------------------------------------------------------- the ABI
// Seven functions, all `callconv(.c)`, all implemented in the linked object. This is the complete
// interface between this board and the editor, and it is deliberately bytes-and-memory only: the
// editor never learns what a UART is, and this file never learns what a cell is.
/// How the editor emits bytes. Called with finished runs of ANSI, many times per frame.
const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void;
/// This board's allocator, handed across as plain function pointers. `log2_align` is a log2 value,
/// which is exactly how `std.mem.Alignment` represents itself, so neither side needs a conversion
/// table.
///
/// The memory belongs to THIS side: only the firmware knows that the heap is the 384 KiB at
/// 0x4FF40000, that the 128 KiB above it is L2 cache, and that PSRAM is untrained. The editor gets
/// an allocator, not an address range.
const Allocator = extern struct {
ctx: ?*anyopaque,
alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8,
resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool,
free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void,
};
/// The one number both sides must agree on. Linkers do not type-check C symbols, so a signature
/// that drifts on one side of this seam links cleanly and then corrupts the stack; checking this
/// before calling anything else turns that into a refusal to boot.
const abi_version: u32 = 1;
extern fn pardes_p4_abi_version() callconv(.c) u32;
/// Hand over the allocator and the output sink, and state the initial window size. Returns 0, or a
/// small non-zero code this file can only report.
extern fn pardes_p4_init(
alloc: *const Allocator,
write: WriteFn,
ctx: ?*anyopaque,
cols: u16,
rows: u16,
) callconv(.c) u32;
/// Raw bytes off the wire: keystrokes, capability-query replies, and the host bridge's in-band
/// resize reports. The editor parses all three; this file distinguishes none of them.
extern fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void;
/// Advance time. Separate from `input` because animations and timeouts must progress on a wire
/// where nothing is arriving.
extern fn pardes_p4_tick(now_ms: u64) callconv(.c) void;
/// Emit one frame through the write callback. Returns 0 or an error code.
extern fn pardes_p4_render() callconv(.c) u32;
/// Is there anything to draw - a dirty surface or a running animation? Asked every iteration so a
/// quiet editor costs no bytes on a 115200-baud link.
extern fn pardes_p4_wants_frame() callconv(.c) bool;
/// Has the user asked to leave? There is nowhere to go, so this only stops the loop.
extern fn pardes_p4_quit() callconv(.c) bool;
/// The last frame's three stages in CPU cycles: the copy of pardes's Surface into vaxis's grid,
/// vaxis's own diff-and-emit, and the push into the UART. Only meaningful under `-Dprof`; the
/// editor object always exports it, and it costs two CSR reads per stage.
extern fn pardes_p4_frame_prof(copy: *u64, render: *u64, flush: *u64) callconv(.c) void;
// ------------------------------------------------------------------------------------ the sink
/// The write callback handed to `pardes_p4_init`. No context is needed - there is one UART.
fn writeOut(_: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void {
uart.write(ptr[0..len]);
}
// ------------------------------------------------------------------------------------- the heap
/// The span the linker script hands over, from `l2high`'s ORIGIN and LENGTH.
///
/// Reached with `@extern`, NOT with `extern const __heap_start: anyopaque` plus
/// `@intFromPtr`/`@ptrFromInt`. That spelling was here first and it was silently wrong: declaring a
/// linker symbol as an `anyopaque` OBJECT gives the optimiser a zero-sized object, so a pointer
/// derived from its address carries provenance for zero bytes, and ordinary (non-volatile) stores
/// through it are dead code it may drop. `examples/heapcheck.zig` caught it on the die - the
/// allocator's first block header read back as `size=2988759312 next=0xffffffff`-not, and the free
/// list walk never terminated. A `[*]u8` from `@extern` has no size to lose.
const heap_start = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_start" });
const heap_end = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_end" });
fn heapSpan() []align(heapmod.Heap.granule) u8 {
return heap_start[0 .. @intFromPtr(heap_end) - @intFromPtr(heap_start)];
}
/// The one heap. A K&R coalescing free list over that span, validated on this die by
/// `examples/heapcheck.zig`: 512 blocks fill and free back to a single 393,216-byte block, a holed
/// arena still satisfies a 4 KiB request, and 20,000 random operations drain back to one block.
var gpa_heap: heapmod.Heap = undefined;
// The four C forwarders the editor is handed. `log2_align` round-trips through
// `std.mem.Alignment`, whose representation IS the log2 value.
fn cAlloc(_: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8 {
const a = gpa_heap.allocator();
return a.vtable.alloc(a.ptr, len, @enumFromInt(log2_align), @returnAddress());
}
fn cResize(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool {
const a = gpa_heap.allocator();
return a.vtable.resize(a.ptr, ptr[0..len], @enumFromInt(log2_align), new_len, @returnAddress());
}
fn cFree(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void {
const a = gpa_heap.allocator();
a.vtable.free(a.ptr, ptr[0..len], @enumFromInt(log2_align), @returnAddress());
}
const editor_allocator: Allocator = .{
.ctx = null,
.alloc = cAlloc,
.resize = cResize,
.free = cFree,
};
// ------------------------------------------------------------------------------------ the clock
/// Milliseconds since boot, off the systimer - a 16 MHz counter (`hal/systimer.zig:31`), which is
/// the cheapest trustworthy clock on this chip. `read` returns null if the unit is not running, in
/// which case time simply does not advance and the editor stops animating; that is a better failure
/// than a clock that jumps.
fn nowMs() u64 {
const us = hal.systimer.micros(.unit0) orelse return 0;
return us / 1000;
}
// ------------------------------------------------------------------------------------- the loop
export fn zig_main() noreturn {
// FIRST, before a single byte of `.rodata` is touched - which means before the marker below,
// because that marker IS a string literal in flash and would read as machine code without this.
soc.flushFlashCache();
const heap = heapSpan();
soc.rom.print("\r\nMARK B3 rom.print heap 0x%08x..0x%08x %u KiB\r\n", .{
@as(u32, @intFromPtr(heap.ptr)),
@as(u32, @intFromPtr(heap.ptr)) + @as(u32, @intCast(heap.len)),
@as(u32, @intCast(heap.len / 1024)),
});
// The CPU clock, before anything is timed against it. The bootloader leaves 90 MHz and the
// CPLL is already at 360, so this is a divider change that disturbs neither UART0 (XTAL) nor
// the systimer (XTAL/2.5) nor the flash interface (SPLL). See hal/clkrst.zig:setCpuFreq.
if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) {
180 => .mhz180,
360 => .mhz360,
else => .mhz90,
});
const rwdt_was_armed = hal.rwdt.disable();
hal.systimer.init();
_ = rwdt_was_armed;
const their_abi = pardes_p4_abi_version();
if (their_abi != abi_version) {
uart.write("MARK PARDES_ABI_MISMATCH\r\n");
while (true) {}
}
gpa_heap = heapmod.Heap.init(heap);
_ = uart.drainInput();
const rc = pardes_p4_init(&editor_allocator, writeOut, null, 80, 24);
if (rc != 0) {
soc.rom.print("MARK PARDES_INIT_FAIL rc=%u\r\n", .{rc});
const s = gpa_heap.stats();
soc.rom.print("MARK PARDES_HEAP free=%u largest=%u blocks=%u\r\n", .{
s.free, s.largest_free, s.free_blocks,
});
while (true) {}
}
// The CPU clock, measured rather than assumed. Every cycle count this firmware reports is
// divided by it somewhere, and `src/io/chip.zig` records it as "a measured ~90 MHz" that
// nothing here reconfigures - so it is worth printing rather than remembering. The systimer is
// XTAL/2.5 = 16 MHz and is NOT derived from the CPU clock (`hal/systimer.zig:31`,
// `clk_tree_defs.h:196-198`), which is exactly what makes it a valid reference for measuring it.
if (prof) {
const t_start = hal.systimer.micros(.unit0) orelse 0;
const c_start = soc.cycles();
// 50 ms is long enough that the systimer's 16 MHz granularity and the loop's own overhead
// are both noise, and short enough to be invisible in a boot.
while ((hal.systimer.micros(.unit0) orelse 0) -% t_start < 50_000) {}
const elapsed_us = (hal.systimer.micros(.unit0) orelse 0) -% t_start;
const elapsed_cy = soc.cycles() - c_start;
soc.rom.print("MARK CPU_HZ cycles=%u us=%u khz=%u\r\n", .{
@as(u32, @intCast(elapsed_cy)),
@as(u32, @intCast(elapsed_us)),
@as(u32, @intCast(if (elapsed_us > 0) elapsed_cy * 1000 / elapsed_us else 0)),
});
}
soc.rom.print("MARK PARDES_READY\r\n", .{});
var in: [256]u8 = undefined;
while (!pardes_p4_quit()) {
// ATTRIBUTION. The host can time a keystroke's round trip but cannot see what the firmware
// spent it on, and the two candidates - parsing and editing, versus rendering - want
// opposite fixes. `soc.cycles()` is the unprivileged cycle counter, so this costs two CSR
// reads per phase and quantises at one cycle, which is four orders of magnitude below the
// milliseconds being attributed. Gated on `prof` so the shipping build carries none of it.
const n = uart.read(&in);
var input_cy: u64 = 0;
if (n > 0) {
const t0 = if (prof) soc.cycles() else 0;
pardes_p4_input(&in, n);
if (prof) input_cy = soc.cycles() - t0;
}
pardes_p4_tick(nowMs());
// Only when there is something to show. On a link this slow an unconditional repaint per
// iteration would saturate the wire and starve input.
if (pardes_p4_wants_frame()) {
const t0 = if (prof) soc.cycles() else 0;
const err = pardes_p4_render();
if (err != 0) soc.rom.print("MARK PARDES_RENDER_FAIL rc=%u\r\n", .{err});
if (prof) {
const render_cy = soc.cycles() - t0;
// A SECOND render with nothing changed since the first. It splits the cost in two:
// whatever this still costs is the price of walking and diffing the whole editor
// state, paid regardless of output, while the difference between the two is the
// price of the change itself. `wants_frame` is false now, so this only happens
// under -Dprof and never on a shipping build.
const t1 = soc.cycles();
_ = pardes_p4_render();
const idle_cy = soc.cycles() - t1;
// Reported in cycles, not microseconds: the divisor is the CPU clock, which this
// firmware does not set and has only ever measured, so converting here would bake a
// guess into the data. `experiments/` divides by the clock it measured.
var copy_cy: u64 = 0;
var vx_cy: u64 = 0;
var flush_cy: u64 = 0;
pardes_p4_frame_prof(©_cy, &vx_cy, &flush_cy);
soc.rom.print("PROF in=%u render=%u idle=%u copy=%u vaxis=%u flush=%u\r\n", .{
@as(u32, @intCast(input_cy)),
@as(u32, @intCast(render_cy)),
@as(u32, @intCast(idle_cy)),
@as(u32, @intCast(copy_cy)),
@as(u32, @intCast(vx_cy)),
@as(u32, @intCast(flush_cy)),
});
}
}
}
soc.rom.print("\r\nMARK PARDES_QUIT\r\n", .{});
while (true) {}
}
// ------------------------------------------------------------------------------------ the trap
/// A trap handler, because the absence of one is why this port has been guessing.
///
/// The mask ROM prints "Guru Meditation" for a trap only while ITS handler is still installed;
/// anything this image does that replaces or outgrows that path fails silently instead, and a silent
/// fault is indistinguishable from an infinite loop over a serial line. This one reports the three
/// registers that name the fault and then stops, using the direct-FIFO writer so it shares nothing
/// with the editor's buffered output.
///
/// `mtvec` is set in DIRECT mode (low two bits zero), so every trap and every interrupt lands on
/// `trapEntry` regardless of cause - which is what a diagnostic wants.
export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn {
asm volatile ("j trapReport");
}
export fn trapReport() noreturn {
const mcause = asm volatile ("csrr %[o], mcause"
: [o] "=r" (-> u32),
);
const mepc = asm volatile ("csrr %[o], mepc"
: [o] "=r" (-> u32),
);
const mtval = asm volatile ("csrr %[o], mtval"
: [o] "=r" (-> u32),
);
uart.write("\r\nMARK TRAP mcause=");
uart.dumpWord(mcause);
uart.write("MARK TRAP mepc=");
uart.dumpWord(mepc);
uart.write("MARK TRAP mtval=");
uart.dumpWord(mtval);
uart.write("MARK TRAP dropped=");
uart.dumpWord(uart.dropped);
while (true) {}
}
// --------------------------------------------------------------------------- the root's own duties
/// `page_size_min`/`max`: the board has no MMU and no pages, but std derives allocator alignment
/// from these. 4 KiB is the ESP32-P4's cache and DMA granularity.
///
/// `logFn` is not cosmetic. std's default log implementation reaches `std.debug_io`, which
/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which
/// exist here, and one `log.warn` from anywhere is enough to drag all of it into the image.
pub const std_options: std.Options = .{
.page_size_min = 4096,
.page_size_max = 4096,
.logFn = logFn,
};
fn logFn(
comptime level: std.log.Level,
comptime scope: @EnumLiteral(),
comptime fmt: []const u8,
args: anytype,
) void {
var buf: [256]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n", args) catch
"\r\n[log overflow]\r\n";
uart.write(line);
}
pub const panic = std.debug.FullPanic(panicImpl);
fn panicImpl(msg: []const u8, _: ?usize) noreturn {
// The ROM path deliberately: a panic may BE the console writer failing, and `ets_printf` shares
// nothing with `uart.write` except the FIFO itself.
soc.rom.print("\r\nMARK PARDES_PANIC %s\r\n", .{msg.ptr});
while (true) {}
}
/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so:
/// enable the F extension (`mstatus.FS`, which ESP-IDF only ever turns on lazily from a trap handler
/// this image does not have), establish a stack, clear `.bss`, and call into Zig.
///
/// The cache invalidate that this image also needs is the FIRST thing `zig_main` does, not something
/// done here. Hand-written `la t0, Cache_Invalidate_All` against an absolute linker symbol computed
/// a PC-relative target and jumped into nowhere (measured: PC=0x88b5d788 with the argument stranded
/// in a2); Zig generates the addressing for an `extern fn` correctly, and `zig_main` runs before any
/// `.rodata` is touched anyway.
export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
asm volatile (
\\ li t0, 1 << 13
\\ csrs mstatus, t0
\\ la sp, __stack_top
\\ mv fp, sp
\\ la t0, trapEntry
\\ csrw mtvec, t0
\\ la t0, __bss_start
\\ la t1, __bss_end
\\ bgeu t0, t1, 2f
\\1:
\\ sw zero, 0(t0)
\\ addi t0, t0, 4
\\ bltu t0, t1, 1b
\\2:
\\ j zig_main
);
}
|