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
|
//! 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 loop
export fn zig_main() noreturn {
// The very first thing, through the TX FIFO directly rather than the mask ROM. Two independent
// output paths matter during bring-up: if this line is clean and `soc.rom.print` below is
// garbage, the fault is in the ROM path (or in something this image did to the ROM's statics);
// if this line is already garbage, the fault is before it, in the entry or the clocks.
uart.write("\r\nMARK PARDES_ENTRY direct-fifo\r\n");
// Self-consistent probe: take the literal's OWN address at run time and dump both it and the
// bytes there. Comparing a runtime read against `llvm-objdump` of a DIFFERENT build is how this
// investigation wasted a cycle - every literal moves when the file changes.
const lit = "\r\nMARK PARDES_ENTRY direct-fifo\r\n";
uart.writeByte('<');
uart.dumpWord(@intFromPtr(lit.ptr)); // where the linker says the literal is
uart.dumpHex(@intFromPtr(lit.ptr), 8); // what a volatile read sees there
uart.writeByte('|');
uart.write(lit); // what the ordinary slice path sends
uart.writeByte('>');
uart.writeByte('\r');
// Page 3 of .flash.rodata. The editor's allocator vtable lives at 0x40035a1c and a runtime load
// of its first entry returned instruction-looking garbage, while page 4 (the literal above, and
// the allocator struct this file passes over) reads correctly. So read page 3 raw and compare
// against llvm-objdump.
// Walk page 3 at 8 KiB steps. If a load at offset 0 is right and one at 0x5a1c is wrong, the
// aliasing granularity is FINER than the 64 KiB `tools/image.zig` assumes for congruence - which
// would mean the flashed bootloader was built with a smaller CONFIG_MMU_PAGE_SIZE (the P4's page
// size is configurable, and the image builder's congruence check is only as strong as the page
// size it believes in). Where the first mismatch falls names the real size.
// A CONTIGUOUS 192 bytes across a known-bad address. The image and the ELF agree here and the
// flash is MD5-verified against the image, so the wrong bytes are produced between the flash and
// the load. If the corruption comes in 64-byte chunks with correct data either side, it is cache
// lines; if it is a clean run of thousands of bytes, it is a mapping.
uart.dumpHex(0x4003_59c0, 64);
uart.dumpHex(0x4003_5a00, 64);
uart.dumpHex(0x4003_5a40, 64);
uart.dumpHex(0x4004_0000, 8);
uart.writeByte(']');
uart.writeByte('\r');
uart.writeByte('\n');
uart.writeByte('\n');
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.
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
);
}
|