diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 12:40:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:46:51 -0300 |
| commit | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch) | |
| tree | 2731a3ed4e51cae09e184e25778eded5fc37d1f5 /tools/rom.zig | |
| download | esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip | |
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig
turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask
ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker.
src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers;
src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip;
src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'tools/rom.zig')
| -rw-r--r-- | tools/rom.zig | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/tools/rom.zig b/tools/rom.zig new file mode 100644 index 0000000..bbb704e --- /dev/null +++ b/tools/rom.zig @@ -0,0 +1,262 @@ +//! The Espressif ROM loader protocol, enough of it to flash a chip: SLIP framing, SYNC, flash +//! attach, and uncompressed block writes. No software stub is uploaded - the ROM can do all of +//! this by itself, and for a ~1 KB image the stub's compression and 16 KB blocks buy nothing. +//! +//! Frame format (esptool loader.py:526-534, 577): +//! request: C0 | 00 op len16 chk32 | payload | C0 +//! response: C0 | 01 op len16 val32 | data | C0 +//! with C0 -> DB DC and DB -> DB DD inside the frame. +//! +//! The ESP32 ROM loaders (unlike the ESP8266's, and unlike the software stub) append FOUR trailing +//! bytes to every response: status, reason, and two reserved bytes (esptool loader.py:653-655, +//! 676). Reading the status at data[len - 2] therefore reads a reserved byte and turns every ROM +//! error into a success - which is exactly the bug an adversarial review of this file found, after +//! driving it over a pty with a rejected FLASH_DATA block. + +const std = @import("std"); +const Port = @import("serial.zig").Port; + +pub const Cmd = enum(u8) { + flash_begin = 0x02, + flash_data = 0x03, + flash_end = 0x04, + sync = 0x08, + read_reg = 0x0A, + spi_set_params = 0x0B, + spi_attach = 0x0D, + change_baud = 0x0F, + spi_flash_md5 = 0x13, + get_security_info = 0x14, +}; + +pub const Error = error{ + SyncFailed, + CommandFailed, + ShortResponse, + Timeout, + BadFrame, +}; + +pub const Loader = struct { + port: *Port, + /// Bytes already read from the port but not yet consumed by the frame parser. Reading a byte + /// at a time costs a poll+read syscall pair each, which turned a 1.5 KB flash into a 600 ms + /// affair; refilling in bursts brings it under 60 ms. + rx: [1024]u8 = undefined, + rx_len: usize = 0, + rx_pos: usize = 0, + + /// The ROM's own block size. The stub raises this to 0x4000; we do not use the stub. + pub const block_size = 0x400; + + fn nextByte(l: *Loader, deadline_ms: i64) !?u8 { + while (l.rx_pos == l.rx_len) { + const remaining = deadline_ms - l.port.nowMs(); + if (remaining <= 0) return null; + const n = try l.port.readTimeout(&l.rx, @intCast(@min(remaining, 50))); + if (n == 0) continue; + l.rx_len = n; + l.rx_pos = 0; + } + defer l.rx_pos += 1; + return l.rx[l.rx_pos]; + } + + /// Consume input until the line has been quiet for `quiet_ms`, but never for longer than + /// twenty such windows: a board stuck in a brownout-reset loop re-prints its ROM banner + /// forever, and an unbounded version of this loop hangs the flash with no output at all. + fn drainUntilQuiet(l: *Loader, quiet_ms: i64) void { + l.rx_pos = 0; + l.rx_len = 0; + const deadline = l.port.nowMs() + 20 * quiet_ms; + while (l.port.nowMs() < deadline) { + const n = l.port.readTimeout(&l.rx, @intCast(quiet_ms)) catch return; + if (n == 0) return; + } + } + + fn frame(gpa: std.mem.Allocator, cmd: Cmd, payload: []const u8, checksum: u32) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + var head: [8]u8 = undefined; + head[0] = 0x00; + head[1] = @intFromEnum(cmd); + std.mem.writeInt(u16, head[2..4], @intCast(payload.len), .little); + std.mem.writeInt(u32, head[4..8], checksum, .little); + + try out.append(gpa, 0xC0); + for (head) |b| try escape(gpa, &out, b); + for (payload) |b| try escape(gpa, &out, b); + try out.append(gpa, 0xC0); + return out.toOwnedSlice(gpa); + } + + fn escape(gpa: std.mem.Allocator, out: *std.ArrayList(u8), b: u8) !void { + switch (b) { + 0xC0 => try out.appendSlice(gpa, &.{ 0xDB, 0xDC }), + 0xDB => try out.appendSlice(gpa, &.{ 0xDB, 0xDD }), + else => try out.append(gpa, b), + } + } + + /// Read one SLIP frame, un-escaping as it goes. + fn readFrame(l: *Loader, gpa: std.mem.Allocator, timeout_ms: u32) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + var started = false; + var escaping = false; + const deadline = l.port.nowMs() + @as(i64, timeout_ms); + while (try l.nextByte(deadline)) |b| { + if (!started) { + if (b == 0xC0) started = true; + continue; + } + if (escaping) { + try out.append(gpa, switch (b) { + 0xDC => 0xC0, + 0xDD => 0xDB, + else => return Error.BadFrame, + }); + escaping = false; + continue; + } + switch (b) { + 0xDB => escaping = true, + 0xC0 => { + if (out.items.len == 0) continue; // empty frame, keep looking + return out.toOwnedSlice(gpa); + }, + else => try out.append(gpa, b), + } + } + return Error.Timeout; + } + + /// Send a command and wait for its matching response. Returns the response `val` field, and + /// copies any leading response data into `out` when one is given. + pub fn command( + l: *Loader, + gpa: std.mem.Allocator, + cmd: Cmd, + payload: []const u8, + checksum: u32, + timeout_ms: u32, + out: ?[]u8, + ) !u32 { + const pkt = try frame(gpa, cmd, payload, checksum); + defer gpa.free(pkt); + try l.port.write(pkt); + + const want_data: usize = if (out) |o| o.len else 0; + var tries: usize = 0; + while (tries < 100) : (tries += 1) { + const resp = l.readFrame(gpa, timeout_ms) catch |err| return err; + defer gpa.free(resp); + // Skip anything that is not this command's reply: stale frames from a previous + // session, or the ROM's repeated SYNC echoes. + if (resp.len < 8) continue; + if (resp[0] != 0x01 or resp[1] != @intFromEnum(cmd)) continue; + + const val = std.mem.readInt(u32, resp[4..8], .little); + const data = resp[8..]; + // Status sits after the expected payload. The ROM appends four bytes (status, reason, + // two reserved) where the stub appends two; esptool tolerates either, so gate on two + // and read the status at the payload end - reading it at len-2 is what made every ROM + // error look like success. + if (data.len < want_data + 2) return Error.ShortResponse; + if (data[want_data] != 0) return Error.CommandFailed; + if (out) |o| @memcpy(o, data[0..want_data]); + return val; + } + return Error.Timeout; + } + + pub fn sync(l: *Loader, gpa: std.mem.Allocator) !void { + var payload: [36]u8 = undefined; + payload[0..4].* = .{ 0x07, 0x07, 0x12, 0x20 }; + @memset(payload[4..], 0x55); + var attempt: usize = 0; + // Short per-attempt timeout: the first SYNC after a reset usually lands before the ROM is + // listening, and waiting 200 ms for that is most of the flash time on a small image. + while (attempt < 20) : (attempt += 1) { + // A reset that did not take is the usual reason SYNC never answers, so re-run it + // periodically rather than failing the build - esptool retries the whole connect + // seven times for the same reason (loader.py:891-899). + if (attempt > 0 and attempt % 5 == 0) l.port.resetToDownload(.{}) catch {}; + if (l.command(gpa, .sync, &payload, 0, 40, null)) |_| { + // The ROM answers SYNC eight times. Swallow the echoes, but stop as soon as the + // line goes quiet instead of burning a fixed 350 ms. + l.drainUntilQuiet(15); + return; + } else |_| {} + } + return Error.SyncFailed; + } + + pub fn attachFlash(l: *Loader, gpa: std.mem.Allocator) !void { + var payload: [8]u8 = @splat(0); // default SPI pins, not legacy + _ = try l.command(gpa, .spi_attach, &payload, 0, 3000, null); + } + + pub fn setFlashParams(l: *Loader, gpa: std.mem.Allocator, total_size: u32) !void { + var payload: [24]u8 = undefined; + std.mem.writeInt(u32, payload[0..4], 0, .little); // fl_id, ignored by the ROM + std.mem.writeInt(u32, payload[4..8], total_size, .little); + std.mem.writeInt(u32, payload[8..12], 64 * 1024, .little); // block + std.mem.writeInt(u32, payload[12..16], 4 * 1024, .little); // sector + std.mem.writeInt(u32, payload[16..20], 256, .little); // page + std.mem.writeInt(u32, payload[20..24], 0xFFFF, .little); // status mask + _ = try l.command(gpa, .spi_set_params, &payload, 0, 3000, null); + } + + /// Write `data` at `offset`. The ROM erases synchronously inside FLASH_BEGIN. + pub fn writeFlash(l: *Loader, gpa: std.mem.Allocator, offset: u32, data: []const u8) !void { + const blocks: u32 = @intCast(std.math.divCeil(usize, data.len, block_size) catch unreachable); + + var begin: [20]u8 = undefined; + std.mem.writeInt(u32, begin[0..4], @intCast(data.len), .little); // erase size + std.mem.writeInt(u32, begin[4..8], blocks, .little); + std.mem.writeInt(u32, begin[8..12], block_size, .little); + std.mem.writeInt(u32, begin[12..16], offset, .little); + std.mem.writeInt(u32, begin[16..20], 0, .little); // not encrypted + const erase_timeout: u32 = @intCast(@max(@as(usize, 3000), data.len / 1024 * 30)); + _ = try l.command(gpa, .flash_begin, &begin, 0, erase_timeout, null); + + var seq: u32 = 0; + var sent: usize = 0; + var block_buf: [16 + block_size]u8 = undefined; + while (sent < data.len) : (seq += 1) { + const take = @min(block_size, data.len - sent); + const chunk = data[sent .. sent + take]; + std.mem.writeInt(u32, block_buf[0..4], block_size, .little); + std.mem.writeInt(u32, block_buf[4..8], seq, .little); + std.mem.writeInt(u32, block_buf[8..12], 0, .little); + std.mem.writeInt(u32, block_buf[12..16], 0, .little); + @memcpy(block_buf[16 .. 16 + take], chunk); + @memset(block_buf[16 + take ..], 0xFF); // ROM writes whole blocks; pad with erased value + + var checksum: u32 = 0xEF; + for (block_buf[16..]) |b| checksum ^= b; + + var attempt: usize = 0; + while (true) : (attempt += 1) { + if (l.command(gpa, .flash_data, &block_buf, checksum, 3000, null)) |_| break else |err| { + if (attempt >= 2) return err; + } + } + sent += take; + } + } + + /// MD5 over a flash range, as 32 ASCII hex characters from the ROM. Routed through + /// `command()` so the direction byte, the opcode and the status are all checked - this is the + /// only thing standing between a rejected block and a bricked image. + pub fn flashMd5(l: *Loader, gpa: std.mem.Allocator, offset: u32, len: u32, out: *[32]u8) !void { + var payload: [16]u8 = undefined; + std.mem.writeInt(u32, payload[0..4], offset, .little); + std.mem.writeInt(u32, payload[4..8], len, .little); + std.mem.writeInt(u32, payload[8..12], 0, .little); + std.mem.writeInt(u32, payload[12..16], 0, .little); + _ = try l.command(gpa, .spi_flash_md5, &payload, 0, @max(1000, len / 1024 * 8), out); + } +}; |
