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
|
//! 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 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 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 cache, flushed
/// Evict every flash-backed cache line, by reading more flash than the caches can hold.
///
/// This is a workaround for a real defect in the hand-over, and it is worth writing down exactly
/// what was measured, because everything cheaper was tried first and every one of them said the
/// hardware was fine:
///
/// * The MMU table is correct. Entries 0..9 read 0x1001..0x100a - the valid bit plus physical
/// page N+1 - which is precisely what the image builder's single flash-to-vaddr anchor requires,
/// and entries 10..11 are unmapped as they should be.
/// * The flash is correct. `zig build flash` verifies an MD5 of what the ROM stored, and the image
/// matches the ELF byte for byte at the addresses that misread.
/// * The page size is not in question: it is hardwired to 64 KiB on this chip
/// (hal/esp32p4/mmu_ll.h:126-130 returns MMU_PAGE_64KB and the setter asserts it).
///
/// And yet a load at 0x40035a1c returned `93 85 85 0f`, which is this image's own `.text`. Reading
/// 512 KiB to force capacity eviction made the same load return `3c ee 08 40`, which is what the
/// image holds there. So the second-stage bootloader hands over with cache lines that do not match
/// the mapping it finally installed. It is perfectly deterministic - the same lines every boot,
/// because the bootloader does the same thing every boot - which is exactly why it looked like
/// anything other than a cache for so long.
///
/// The ROM's own `Cache_Invalidate_All` (0x4fc00404, same address in both esp32p4.rom.ld and the
/// eco5 table) would be the right instrument and is NOT used: called from here it faults inside ROM
/// code with the argument stranded in a2, so it wants a precondition this image does not know about.
/// A capacity flush needs no such knowledge. It costs one pass over 512 KiB of already-mapped flash,
/// once, at boot.
///
/// 512 KiB is four times the 128 KiB the L2 measured at (examples/memprobe.zig found real RAM
/// stopping at 0x4FFA0000, the cache taking the rest), with the L1s smaller still. The stride is one
/// 64-byte line. `volatile` and a summed sink so nothing here can be optimised away.
fn flushFlashCache() void {
var sink: u32 = 0;
var p: u32 = 0x4000_0000;
while (p < 0x4008_0000) : (p += 64) {
sink +%= @as(*volatile u32, @ptrFromInt(p)).*;
}
// Consumed through a volatile store so the whole loop cannot be discarded as dead.
@as(*volatile u32, &cache_flush_sink).* = sink;
}
var cache_flush_sink: u32 = 0;
// ------------------------------------------------------------------------------------- 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.
flushFlashCache();
uart.write("\r\nMARK PARDES_ENTRY direct-fifo\r\n");
// Ask the MMU what it actually mapped, which is the one measurement that settles where the
// wrong bytes come from. The flash MMU table is not memory-mapped: entry N is read by writing N
// to SPI_MEM_C_MMU_ITEM_INDEX_REG and reading SPI_MEM_C_MMU_ITEM_CONTENT_REG
// (hal/esp32p4/mmu_ll.h:311-330). Page size is hardwired to 64 KiB on this chip
// (mmu_ll.h:126-130 returns MMU_PAGE_64KB and the setter asserts it), so entry N covers
// vaddr 0x40000000 + N*0x10000.
//
// Both mapped segments share one flash-to-vaddr delta by construction (the image builder anchors
// them), and that delta is 0x010000 - 0x00000000, so EVERY entry N should hold physical page
// N+1. Any entry that does not is the bug, and its value says which flash page it points at
// instead.
const mmu_index: *volatile u32 = @ptrFromInt(0x5008_C380);
const mmu_content: *volatile u32 = @ptrFromInt(0x5008_C37C);
uart.write("MARK MMU entries 0..11 (expect N+1)\r\n");
var e: u32 = 0;
while (e < 12) : (e += 1) {
mmu_index.* = e;
uart.dumpWord(mmu_content.*);
}
// The MMU is provably right, the flash is MD5-verified and the image matches the ELF, yet a load
// at 0x40035a1c returns instruction bytes. The only layer left is the cache. A stale line would
// be deterministic across resets - the bootloader repeats the same sequence every boot - so
// determinism did NOT rule it out earlier.
//
// So: read it, evict by walking far more data than any cache here can hold, then read it again.
// If the second read is correct, the first was a stale line and the fix belongs at startup.
uart.write("MARK CACHE before/after eviction\r\n");
uart.dumpHex(0x4003_5a1c, 8);
{
// 512 KiB touched at 64-byte line stride, summed so nothing can be optimised away.
var sink: u32 = 0;
var p: u32 = 0x4000_0000;
while (p < 0x4008_0000) : (p += 64) {
sink +%= @as(*volatile u32, @ptrFromInt(p)).*;
}
uart.dumpWord(sink);
}
uart.dumpHex(0x4003_5a1c, 8);
uart.write("MARK B1 entry ok\r\n");
const heap = heapSpan();
uart.write("MARK B2 heapSpan ok\r\n");
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)),
});
uart.write("MARK B4 rom.print returned\r\n");
const rwdt_was_armed = hal.rwdt.disable();
uart.write("MARK B5 rwdt ok\r\n");
hal.systimer.init();
uart.write("MARK B6 systimer ok\r\n");
_ = rwdt_was_armed;
const their_abi = pardes_p4_abi_version();
uart.write("MARK B7 abi call returned\r\n");
if (their_abi != abi_version) {
uart.write("MARK PARDES_ABI_MISMATCH\r\n");
while (true) {}
}
gpa_heap = heapmod.Heap.init(heap);
uart.write("MARK B8 heap init ok\r\n");
_ = uart.drainInput();
uart.write("MARK B9 drain ok, calling pardes_p4_init\r\n");
const rc = pardes_p4_init(&editor_allocator, writeOut, null, 80, 24);
uart.write("MARK B10 pardes_p4_init returned\r\n");
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) {}
}
soc.rom.print("MARK PARDES_READY\r\n", .{});
var in: [256]u8 = undefined;
while (!pardes_p4_quit()) {
const n = uart.read(&in);
if (n > 0) pardes_p4_input(&in, n);
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 err = pardes_p4_render();
if (err != 0) soc.rom.print("MARK PARDES_RENDER_FAIL rc=%u\r\n", .{err});
}
}
soc.rom.print("\r\nMARK PARDES_QUIT\r\n", .{});
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, __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
);
}
|